agora inbox for pgsql-bugs@postgresql.org
help / color / mirror / Atom feedMERGE/SPLIT PARTITIONS issues/questions
40+ messages / 8 participants
[nested] [flat]
* MERGE/SPLIT PARTITIONS issues/questions
@ 2026-07-23 11:59 Zsolt Parragi <zsolt.parragi@percona.com>
0 siblings, 2 replies; 40+ messages in thread
From: Zsolt Parragi @ 2026-07-23 11:59 UTC (permalink / raw)
To: pgsql-bugs@lists.postgresql.org
Hello,
I have multiple questions and potential issues with MERGE PARTITIONS /
SPLIT PARTITIONS. I have ideas for fixing some of these problems, but
not all of them, so I'd like to just discuss them before proposing
anything specific:
1. Moved rows are inserted with plain heap inserts, so they are
decoded as INSERTs into the new partition, with no matching deletes.
This should either emit matching deletes before, or also skip the
inserts, as the current behavior seems to break logical replication.
The latter looks like a better solution to me, but I am not 100% sure
about it.
2. Should the new partition inherit direct publication membership from
the partitions it replaces, especially for a split where this is
clear? For a merge it's harder to argue about if the original
partitions are different.
Similarly what about replica identity?
3. What's the proper process to propagate a merge/split to a
subscriber without data loss?
For now let's assume that we implement the "no generated inserts"
change I mentioned above, so that it at least works.
* Everything in sync at the beginning
* Merge command executed on publisher
* An UPDATE targeting a merged row is executed on the publishers
* Subscriber stops: can't execute the UPDATE
* Subscriber needs a manual MERGE replay, table now exists locally,
but it is not part of the subscription
* Apply worker retries the update, sees the table, but it's not part
of the subscription, so it drops the update
* User runs REFRESH PUBLICATION with copy_table=false because the data
is already there, the previous update was lost
So seems like the working approach is either to TRUNCATE before
REFRESH PUBLICATION, or to manually DROP/CREATE the partitions? Should
this be documented somewhere?
4. Earlier I wrote that "the data didn't change"... but generated
columns can silently change:
CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
PARTITION BY RANGE (id);
CREATE TABLE t1 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
ALTER TABLE t ATTACH PARTITION t1 FOR VALUES FROM (0) TO (10);
CREATE TABLE t2 PARTITION OF t FOR VALUES FROM (10) TO (20);
INSERT INTO t VALUES (3), (12);
-- 3|300 12|24
SELECT id, g FROM t ORDER BY id;
ALTER TABLE t MERGE PARTITIONS (t1, t2) INTO t12;
-- 3|6 12|24
SELECT id, g FROM t ORDER BY id;
Or another example:
CREATE FUNCTION f(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
CREATE TABLE t (id int, g int GENERATED ALWAYS AS (f(id)) STORED)
PARTITION BY RANGE (id);
CREATE TABLE t1 PARTITION OF t FOR VALUES FROM (0) TO (10);
CREATE TABLE t2 PARTITION OF t FOR VALUES FROM (10) TO (20);
INSERT INTO t VALUES (3), (12);
-- 3|6 12|24
SELECT id, g FROM t ORDER BY id;
CREATE OR REPLACE FUNCTION f(i int) RETURNS int IMMUTABLE LANGUAGE sql
AS 'SELECT i * 100';
VACUUM FULL t; -- same result with unrelated rewriting alter
-- 3|6 12|24
SELECT id, g FROM t ORDER BY id;
ALTER TABLE t MERGE PARTITIONS (t1, t2) INTO t12;
-- 3|300 12|1200
SELECT id, g FROM t ORDER BY id;
This example is especially interesting because with VACUUM FULL or an
unrelated rewriting ALTER TABLE, the data remains unchanged, so while
this is a corner case, it can be surprising for users.
The second example seems fixable to me, even if difficult, but I'm not
sure what would be a good approach for the first, other than erroring
out instead?
5. In (2) I mentioned replication-related inheritance questions, but
it is much more generic than that, many partition specific details get
lost silently:
* indexes
* constraints
* different DEFAULTs
* foreign keys
* triggers
* reloptions
* custom tablespace
* table AM
* per column settings
* security labels
* ACLs
* RLS policies
Shouldn't most of these copied into split partitions, and handled
properly in merges (erroring out in non trivial cases?)
Silently dropping them doesn't seem like a good behavior, as it can
cause many different issues:
* dropping foreign keys / checks can cause data integrity issues
* dropping partition specific sequences can cause later inserts to
fail or silently fall back to nulls/different values
* probably many other scenarios I didn't think of
What do you think about the above points, how would you fix them? Are
(some of) these acceptable as limitations/known issues for the feature
in 19?
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-07-23 14:29 Zsolt Parragi <zsolt.parragi@percona.com>
parent: Zsolt Parragi <zsolt.parragi@percona.com>
1 sibling, 1 reply; 40+ messages in thread
From: Zsolt Parragi @ 2026-07-23 14:29 UTC (permalink / raw)
To: pgsql-bugs@lists.postgresql.org
> 4. Earlier I wrote that "the data didn't change"... but generated
> columns can silently change:
This point is a bit worse than my original description, I was able to
break some constraints with it. See attached reproducer scripts:
* merge-dangling-fk.sql results in a foreign key that appears to be
validated but contains dangling entries
* merge-invalid-check.sql breaks a check constraint
* merge-null-assert.sql inserts a NULL value into a NOT NULL column.
Crashes the debug build with an assertion, returns inconsistent data
in production builds.
On Thu, Jul 23, 2026 at 12:59 PM Zsolt Parragi
<zsolt.parragi@percona.com> wrote:
>
> Hello,
>
> I have multiple questions and potential issues with MERGE PARTITIONS /
> SPLIT PARTITIONS. I have ideas for fixing some of these problems, but
> not all of them, so I'd like to just discuss them before proposing
> anything specific:
>
> 1. Moved rows are inserted with plain heap inserts, so they are
> decoded as INSERTs into the new partition, with no matching deletes.
> This should either emit matching deletes before, or also skip the
> inserts, as the current behavior seems to break logical replication.
> The latter looks like a better solution to me, but I am not 100% sure
> about it.
>
> 2. Should the new partition inherit direct publication membership from
> the partitions it replaces, especially for a split where this is
> clear? For a merge it's harder to argue about if the original
> partitions are different.
> Similarly what about replica identity?
>
> 3. What's the proper process to propagate a merge/split to a
> subscriber without data loss?
> For now let's assume that we implement the "no generated inserts"
> change I mentioned above, so that it at least works.
> * Everything in sync at the beginning
> * Merge command executed on publisher
> * An UPDATE targeting a merged row is executed on the publishers
> * Subscriber stops: can't execute the UPDATE
> * Subscriber needs a manual MERGE replay, table now exists locally,
> but it is not part of the subscription
> * Apply worker retries the update, sees the table, but it's not part
> of the subscription, so it drops the update
> * User runs REFRESH PUBLICATION with copy_table=false because the data
> is already there, the previous update was lost
>
> So seems like the working approach is either to TRUNCATE before
> REFRESH PUBLICATION, or to manually DROP/CREATE the partitions? Should
> this be documented somewhere?
>
> 4. Earlier I wrote that "the data didn't change"... but generated
> columns can silently change:
>
> CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
> PARTITION BY RANGE (id);
> CREATE TABLE t1 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
> ALTER TABLE t ATTACH PARTITION t1 FOR VALUES FROM (0) TO (10);
> CREATE TABLE t2 PARTITION OF t FOR VALUES FROM (10) TO (20);
>
> INSERT INTO t VALUES (3), (12);
> -- 3|300 12|24
> SELECT id, g FROM t ORDER BY id;
>
> ALTER TABLE t MERGE PARTITIONS (t1, t2) INTO t12;
> -- 3|6 12|24
> SELECT id, g FROM t ORDER BY id;
>
> Or another example:
>
> CREATE FUNCTION f(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
> CREATE TABLE t (id int, g int GENERATED ALWAYS AS (f(id)) STORED)
> PARTITION BY RANGE (id);
> CREATE TABLE t1 PARTITION OF t FOR VALUES FROM (0) TO (10);
> CREATE TABLE t2 PARTITION OF t FOR VALUES FROM (10) TO (20);
> INSERT INTO t VALUES (3), (12);
> -- 3|6 12|24
> SELECT id, g FROM t ORDER BY id;
>
> CREATE OR REPLACE FUNCTION f(i int) RETURNS int IMMUTABLE LANGUAGE sql
> AS 'SELECT i * 100';
>
> VACUUM FULL t; -- same result with unrelated rewriting alter
> -- 3|6 12|24
> SELECT id, g FROM t ORDER BY id;
>
> ALTER TABLE t MERGE PARTITIONS (t1, t2) INTO t12;
> -- 3|300 12|1200
> SELECT id, g FROM t ORDER BY id;
>
> This example is especially interesting because with VACUUM FULL or an
> unrelated rewriting ALTER TABLE, the data remains unchanged, so while
> this is a corner case, it can be surprising for users.
>
> The second example seems fixable to me, even if difficult, but I'm not
> sure what would be a good approach for the first, other than erroring
> out instead?
>
> 5. In (2) I mentioned replication-related inheritance questions, but
> it is much more generic than that, many partition specific details get
> lost silently:
> * indexes
> * constraints
> * different DEFAULTs
> * foreign keys
> * triggers
> * reloptions
> * custom tablespace
> * table AM
> * per column settings
> * security labels
> * ACLs
> * RLS policies
>
> Shouldn't most of these copied into split partitions, and handled
> properly in merges (erroring out in non trivial cases?)
>
> Silently dropping them doesn't seem like a good behavior, as it can
> cause many different issues:
> * dropping foreign keys / checks can cause data integrity issues
> * dropping partition specific sequences can cause later inserts to
> fail or silently fall back to nulls/different values
> * probably many other scenarios I didn't think of
>
> What do you think about the above points, how would you fix them? Are
> (some of) these acceptable as limitations/known issues for the feature
> in 19?
Attachments:
[application/octet-stream] merge-invalid-check.sql (810B, ../../CAN4CZFPDESL9LsNQALg1Y4NX2d_6jp8bh9foifhFgkV11+u3rw@mail.gmail.com/2-merge-invalid-check.sql)
download
[application/octet-stream] merge-dangling-fk.sql (1.1K, ../../CAN4CZFPDESL9LsNQALg1Y4NX2d_6jp8bh9foifhFgkV11+u3rw@mail.gmail.com/3-merge-dangling-fk.sql)
download
[application/octet-stream] merge-null-assert.sql (1.1K, ../../CAN4CZFPDESL9LsNQALg1Y4NX2d_6jp8bh9foifhFgkV11+u3rw@mail.gmail.com/4-merge-null-assert.sql)
download
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-01 09:49 jian he <jian.universality@gmail.com>
parent: Zsolt Parragi <zsolt.parragi@percona.com>
0 siblings, 1 reply; 40+ messages in thread
From: jian he @ 2026-08-01 09:49 UTC (permalink / raw)
To: Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: pgsql-bugs@lists.postgresql.org
On Thu, Jul 23, 2026 at 10:29 PM Zsolt Parragi
<zsolt.parragi@percona.com> wrote:
>
> > 4. Earlier I wrote that "the data didn't change"... but generated
> > columns can silently change:
>
> This point is a bit worse than my original description, I was able to
> break some constraints with it. See attached reproducer scripts:
>
> * merge-dangling-fk.sql results in a foreign key that appears to be
> validated but contains dangling entries
> * merge-invalid-check.sql breaks a check constraint
> * merge-null-assert.sql inserts a NULL value into a NOT NULL column.
> Crashes the debug build with an assertion, returns inconsistent data
> in production builds.
>
Previously, we assumed that ALTER TABLE ... MERGE PARTITION simply combined the
contents of multiple partitions into a new partition.
However, the generation expressions defined on the partitions may differ from
those of the partitioned table. As a result, if the table contains generated
columns, the data in the newly created partition may not be identical to the
combined contents of the merged partitions.
Therefore, when the partitioned table contains generated columns, we must
reverify NOT NULL constraints, CHECK constraints, and foreign key constraints
for ALTER TABLE ... MERGE PARTITION.
I combined these fixes into one patch.
some of the comments is directly copied from ATRewriteTable.
--
jian
https://www.enterprisedb.com/
Attachments:
[text/x-patch] v1-0001-reverify-constraint-for-ALTER-TABLE-MERGE-PARTITION.patch (15.8K, ../../CACJufxFEm=vEa=42H9Fe=vQfOBPBA=2+U-ZEK5FOKvV-65Muag@mail.gmail.com/2-v1-0001-reverify-constraint-for-ALTER-TABLE-MERGE-PARTITION.patch)
download | inline diff:
From 6b89e131f59a44021a24717fc893b33ac4b65936 Mon Sep 17 00:00:00 2001
From: jian he <jian.universality@gmail.com>
Date: Sat, 1 Aug 2026 17:46:08 +0800
Subject: [PATCH v1 1/1] reverify constraint for ALTER TABLE MERGE PARTITION
Previously, we assumed that ALTER TABLE ... MERGE PARTITION simply combined the
contents of multiple partitions into a new partition.
However, the generation expressions of the source partitions may differ from
that of the partitioned table. If the partitioned table contains generated
columns, the data in the newly created partition may not be identical to the
combined contents of the merged partitions.
Therefore, when the partitioned table contains generated columns, we need to
reverify constraints after the merge, including NOT NULL constraints, CHECK
constraints, and foreign key constraints.
---
src/backend/commands/tablecmds.c | 203 ++++++++++++++++--
src/test/regress/expected/partition_merge.out | 49 +++++
src/test/regress/sql/partition_merge.sql | 48 +++++
3 files changed, 279 insertions(+), 21 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 6d4c457b820..1a54ffe47ad 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23011,6 +23011,7 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
int ccnum;
List *constraints = NIL;
List *cookedConstraints = NIL;
+ bool newRelhasGenerated;
tupleDesc = RelationGetDescr(parent_rel);
constr = tupleDesc->constr;
@@ -23037,6 +23038,9 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
if (attribute->attisdropped)
continue;
+ if (attribute->attnotnull)
+ tab->verify_new_notnull = true;
+
/* Copy the default, if present, and it should be copied. */
if (attribute->atthasdef)
{
@@ -23079,6 +23083,9 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
newval->is_generated = (attribute->attgenerated != '\0');
tab->newvals = lappend(tab->newvals, newval);
}
+
+ if (!newRelhasGenerated && attribute->attgenerated != '\0')
+ newRelhasGenerated = true;
}
}
@@ -23154,11 +23161,18 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
pull_varattnos(qual, 1, &attnums);
/*
- * Add a check only if it contains a tableoid
+ * Add a check if it contains a tableoid
* (TableOidAttributeNumber).
+ *
+ * ALTER TABLE MERGE PARTITIONS may change the data of the new
+ * partition compared to the combination of the old, merged
+ * partitions, because the generated column expression on the new
+ * partition may differ from the one on the merged partitions.
+ * Therefore, CHECK constraints on the new merged need reverify
+ * again whenever the new table has generated columns.
*/
- if (bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber,
- attnums))
+ if (bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber, attnums) ||
+ newRelhasGenerated)
{
NewConstraint *newcon;
@@ -23364,12 +23378,15 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
CommandId mycid;
EState *estate;
AlteredTableInfo *tab;
- ListCell *ltab;
/* The FSM is empty, so don't bother using it. */
uint32 ti_options = TABLE_INSERT_SKIP_FSM;
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
+ ResultRelInfo *rInfo = NULL;
+ List *notnull_attrs;
+ List *notnull_virtual_attrs;
+ TupleDesc newTupDesc;
/* Find the work queue entry for the new partition table: newPartRel. */
tab = ATGetQueueEntry(wqueue, newPartRel);
@@ -23387,6 +23404,61 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
/* Create the necessary tuple slot. */
dstslot = table_slot_create(newPartRel, NULL);
+ newTupDesc = RelationGetDescr(newPartRel);
+ notnull_attrs = notnull_virtual_attrs = NIL;
+
+ if (tab->verify_new_notnull)
+ {
+ /*
+ * If we are rebuilding the tuples OR if we added any new but not
+ * verified not-null constraints, check all *valid* not-null
+ * constraints. This is a bit of overkill but it minimizes risk of
+ * bugs.
+ *
+ * notnull_attrs does *not* collect attribute numbers for valid
+ * not-null constraints over virtual generated columns; instead, they
+ * are collected in notnull_virtual_attrs for verification elsewhere.
+ */
+ for (int i = 0; i < newTupDesc->natts; i++)
+ {
+ CompactAttribute *attr = TupleDescCompactAttr(newTupDesc, i);
+
+ if (attr->attnullability == ATTNULLABLE_VALID &&
+ !attr->attisdropped)
+ {
+ Form_pg_attribute wholeatt = TupleDescAttr(newTupDesc, i);
+
+ if (wholeatt->attgenerated != ATTRIBUTE_GENERATED_VIRTUAL)
+ notnull_attrs = lappend_int(notnull_attrs, wholeatt->attnum);
+ else
+ notnull_virtual_attrs = lappend_int(notnull_virtual_attrs,
+ wholeatt->attnum);
+ }
+ }
+ }
+
+ /*
+ * When adding or changing a virtual generated column with a not-null
+ * constraint, we need to evaluate whether the generation expression is
+ * null. For that, we borrow ExecRelGenVirtualNotNull(). Here, we
+ * prepare a dummy ResultRelInfo.
+ */
+ if (notnull_virtual_attrs != NIL)
+ {
+ MemoryContext oldcontext;
+
+ Assert(newTupDesc->constr->has_generated_virtual);
+ Assert(newTupDesc->constr->has_not_null);
+ oldcontext = MemoryContextSwitchTo(estate->es_query_cxt);
+ rInfo = makeNode(ResultRelInfo);
+ InitResultRelInfo(rInfo,
+ newPartRel,
+ 0, /* dummy rangetable index */
+ NULL,
+ estate->es_instrument);
+ MemoryContextSwitchTo(oldcontext);
+ }
+
foreach_oid(merging_oid, mergingPartitions)
{
ExprContext *econtext;
@@ -23470,6 +23542,41 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
evaluateGeneratedExpressionsAndCheckConstraints(tab, newPartRel,
insertslot, econtext);
+ foreach_int(attn, notnull_attrs)
+ {
+ if (slot_attisnull(insertslot, attn))
+ {
+ Form_pg_attribute attr = TupleDescAttr(newTupDesc, attn - 1);
+
+ ereport(ERROR,
+ errcode(ERRCODE_NOT_NULL_VIOLATION),
+ errmsg("column \"%s\" of relation \"%s\" contains null values",
+ NameStr(attr->attname),
+ RelationGetRelationName(newPartRel)),
+ errtablecol(newPartRel, attn));
+ }
+ }
+
+ if (notnull_virtual_attrs != NIL)
+ {
+ AttrNumber attnum;
+
+ attnum = ExecRelGenVirtualNotNull(rInfo, insertslot,
+ estate,
+ notnull_virtual_attrs);
+ if (attnum != InvalidAttrNumber)
+ {
+ Form_pg_attribute attr = TupleDescAttr(newTupDesc, attnum - 1);
+
+ ereport(ERROR,
+ errcode(ERRCODE_NOT_NULL_VIOLATION),
+ errmsg("column \"%s\" of relation \"%s\" contains null values",
+ NameStr(attr->attname),
+ RelationGetRelationName(newPartRel)),
+ errtablecol(newPartRel, attnum));
+ }
+ }
+
/* Write the tuple out to the new relation. */
table_tuple_insert(newPartRel, insertslot, mycid,
ti_options, bistate);
@@ -23493,20 +23600,6 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
FreeBulkInsertState(bistate);
table_finish_bulk_insert(newPartRel, ti_options);
-
- /*
- * We don't need to process this newPartRel since we already processed it
- * here, so delete the ALTER TABLE queue for it.
- */
- foreach(ltab, *wqueue)
- {
- tab = (AlteredTableInfo *) lfirst(ltab);
- if (tab->relid == RelationGetRelid(newPartRel))
- {
- *wqueue = list_delete_cell(*wqueue, ltab);
- break;
- }
- }
}
/*
@@ -23756,6 +23849,7 @@ static void
ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
PartitionCmd *cmd, AlterTableUtilityContext *context)
{
+ ListCell *ltab;
Relation newPartRel;
List *mergingPartitions = NIL;
List *extDepState = NIL;
@@ -23765,6 +23859,9 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
Oid save_userid;
int save_sec_context;
int save_nestlevel;
+ AlteredTableInfo *new_partrel_tab;
+ Relation thisrel = NULL;
+ bool hasGenerated = false;
/*
* Check ownership of merged partitions - partitions with different owners
@@ -23926,11 +24023,61 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
list_free(mergingPartitions);
+ /* Attach a new partition to the partitioned table. */
+ attachPartitionTable(wqueue, rel, newPartRel, cmd->bound);
+
+ /* Find the work queue entry for the new partition table: newPartRel. */
+ new_partrel_tab = ATGetQueueEntry(wqueue, newPartRel);
+
/*
- * Attach a new partition to the partitioned table. wqueue = NULL:
- * verification for each cloned constraint is not needed.
+ * ALTER TABLE MERGE PARTITIONS may change the data of the new partition
+ * compared to the combination of the old, merged partitions, because the
+ * gneration expression on the new partition may differ from the one on
+ * the merged partitions. Therefore, foreign key constraints on the new
+ * merged table need reverify whenever the new table has generated
+ * columns.
*/
- attachPartitionTable(NULL, rel, newPartRel, cmd->bound);
+ foreach_ptr(NewColumnValue, ex, new_partrel_tab->newvals)
+ {
+ if (ex->is_generated)
+ {
+ hasGenerated = true;
+ break;
+ }
+ }
+
+ if (hasGenerated)
+ {
+ foreach_ptr(NewConstraint, con, new_partrel_tab->constraints)
+ {
+ Constraint *fkconstraint;
+ Relation refrel;
+
+ if (con->contype != CONSTR_FOREIGN)
+ continue;
+
+ fkconstraint = (Constraint *) con->qual;
+
+ if (thisrel == NULL)
+ {
+ /* Long since locked, no need for another */
+ thisrel = table_open(new_partrel_tab->relid, NoLock);
+ }
+
+ refrel = table_open(con->refrelid, RowShareLock);
+
+ validateForeignKeyConstraint(fkconstraint->conname, thisrel, refrel,
+ con->refindid,
+ con->conid,
+ con->conwithperiod);
+
+ /*
+ * No need to mark the constraint row as validated, we did that
+ * when we inserted the row earlier.
+ */
+ table_close(refrel, NoLock);
+ }
+ }
/*
* Apply extension dependencies to the new partition's indexes. This
@@ -23949,6 +24096,20 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
/* Restore the userid and security context. */
SetUserIdAndSecContext(save_userid, save_sec_context);
+
+ /*
+ * We don't need to process this newPartRel since we already processed it
+ * here, so delete the ALTER TABLE queue for it.
+ */
+ foreach(ltab, *wqueue)
+ {
+ tab = (AlteredTableInfo *) lfirst(ltab);
+ if (tab->relid == RelationGetRelid(newPartRel))
+ {
+ *wqueue = list_delete_cell(*wqueue, ltab);
+ break;
+ }
+ }
}
/*
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index ccda2b5843b..77deded5faa 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1055,6 +1055,55 @@ SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i =
1
(1 row)
+DROP TABLE t;
+-- TEST for recomputation of generated columns with not-null, foreign key and check constraint
+CREATE TABLE t (
+ id int,
+ g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL) PARTITION BY RANGE (id);
+CREATE TABLE tp_0_1 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (1), (2);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- error
+ERROR: column "g" of relation "tp_0_2" contains null values
+DROP TABLE t;
+CREATE TABLE t (
+ id int,
+ g int GENERATED ALWAYS AS (NULLIF(id, 1)) NOT NULL) PARTITION BY RANGE (id);
+CREATE TABLE tp_0_1 (g int GENERATED ALWAYS AS (id) NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (1), (2);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- error
+ERROR: column "g" of relation "tp_0_2" contains null values
+DROP TABLE t;
+CREATE TABLE t (
+ id int NOT NULL,
+ g int GENERATED ALWAYS AS (id + 1000) STORED,
+ CONSTRAINT gcheck CHECK (g < 100)) PARTITION BY RANGE (id);
+CREATE TABLE tp_0_1(
+ id int NOT NULL,
+ g int GENERATED ALWAYS AS (id) STORED,
+ CONSTRAINT gcheck CHECK (g < 100));
+ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (1), (2);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_02; -- error
+ERROR: check constraint "gcheck" of relation "tp_02" is violated by some row
+DROP TABLE t;
+CREATE TABLE t (
+ id int NOT NULL,
+ pid int GENERATED ALWAYS AS (id + 1000) STORED,
+ PRIMARY KEY (id),
+ FOREIGN KEY (pid) REFERENCES t (id)
+) PARTITION BY RANGE (id);
+CREATE TABLE t1 (id int NOT NULL, pid int GENERATED ALWAYS AS (id) STORED);
+ALTER TABLE t ATTACH PARTITION t1 FOR VALUES FROM (0) TO (10);
+CREATE TABLE t2 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (1), (2), (3);
+ALTER TABLE t MERGE PARTITIONS (t1, t2) INTO t12; -- error
+ERROR: insert or update on table "t12" violates foreign key constraint "t_pid_fkey"
+DETAIL: Key (pid)=(1001) is not present in table "t".
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 80dc365b0ce..bd1c7569be5 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -757,6 +757,54 @@ SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i =
DROP TABLE t;
+-- TEST for recomputation of generated columns with not-null, foreign key and check constraint
+CREATE TABLE t (
+ id int,
+ g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL) PARTITION BY RANGE (id);
+CREATE TABLE tp_0_1 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (1), (2);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- error
+DROP TABLE t;
+
+CREATE TABLE t (
+ id int,
+ g int GENERATED ALWAYS AS (NULLIF(id, 1)) NOT NULL) PARTITION BY RANGE (id);
+CREATE TABLE tp_0_1 (g int GENERATED ALWAYS AS (id) NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (1), (2);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- error
+DROP TABLE t;
+
+CREATE TABLE t (
+ id int NOT NULL,
+ g int GENERATED ALWAYS AS (id + 1000) STORED,
+ CONSTRAINT gcheck CHECK (g < 100)) PARTITION BY RANGE (id);
+CREATE TABLE tp_0_1(
+ id int NOT NULL,
+ g int GENERATED ALWAYS AS (id) STORED,
+ CONSTRAINT gcheck CHECK (g < 100));
+ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (1), (2);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_02; -- error
+DROP TABLE t;
+
+CREATE TABLE t (
+ id int NOT NULL,
+ pid int GENERATED ALWAYS AS (id + 1000) STORED,
+ PRIMARY KEY (id),
+ FOREIGN KEY (pid) REFERENCES t (id)
+) PARTITION BY RANGE (id);
+CREATE TABLE t1 (id int NOT NULL, pid int GENERATED ALWAYS AS (id) STORED);
+ALTER TABLE t ATTACH PARTITION t1 FOR VALUES FROM (0) TO (10);
+CREATE TABLE t2 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (1), (2), (3);
+ALTER TABLE t MERGE PARTITIONS (t1, t2) INTO t12; -- error
+DROP TABLE t;
+
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
--
2.34.1
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-01 11:11 Zsolt Parragi <zsolt.parragi@percona.com>
parent: jian he <jian.universality@gmail.com>
0 siblings, 1 reply; 40+ messages in thread
From: Zsolt Parragi @ 2026-08-01 11:11 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: pgsql-bugs@lists.postgresql.org
> As a result, if the table contains generated
> columns, the data in the newly created partition may not be identical to the
> combined contents of the merged partitions.
>
> Therefore, when the partitioned table contains generated columns, we must
> reverify NOT NULL constraints, CHECK constraints, and foreign key constraints
> for ALTER TABLE ... MERGE PARTITION.
Yes, we can certainly can patch it this way. But should we? This
current behavior is inconsistent with how generated columns behave
with other SQL commands. That's why I didn't attach a patch in my
previous emails, I think the current way this behaves is wrong.
My proposal would be to reject MERGE if it would cause a difference in
generator expressions (or if it causes any other surprising changes),
and keep the exact definition of the partition for SPLIT. Otherwise we
end up with a surprising behavior in PG19, and if we want to fix it in
later releases, it'll be a significant behavior change between major
versions for the same command.
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-02 04:27 jian he <jian.universality@gmail.com>
parent: Zsolt Parragi <zsolt.parragi@percona.com>
0 siblings, 1 reply; 40+ messages in thread
From: jian he @ 2026-08-02 04:27 UTC (permalink / raw)
To: Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: pgsql-bugs@lists.postgresql.org
On Sat, Aug 1, 2026 at 7:11 PM Zsolt Parragi <zsolt.parragi@percona.com> wrote:
>
> > As a result, if the table contains generated
> > columns, the data in the newly created partition may not be identical to the
> > combined contents of the merged partitions.
> >
> > Therefore, when the partitioned table contains generated columns, we must
> > reverify NOT NULL constraints, CHECK constraints, and foreign key constraints
> > for ALTER TABLE ... MERGE PARTITION.
>
> Yes, we can certainly can patch it this way. But should we? This
> current behavior is inconsistent with how generated columns behave
> with other SQL commands. That's why I didn't attach a patch in my
> previous emails, I think the current way this behaves is wrong.
>
> My proposal would be to reject MERGE if it would cause a difference in
> generator expressions (or if it causes any other surprising changes),
> and keep the exact definition of the partition for SPLIT. Otherwise we
> end up with a surprising behavior in PG19, and if we want to fix it in
> later releases, it'll be a significant behavior change between major
> versions for the same command.
For ALTER TABLE pp MERGE PARTITIONS (pp1, pp2) INTO pp12, a CHECK constraint
that exists only on pp2 surely should not apply to the new pp12. Otherwise, that
constraint would also end up being enforced against pp1's data, regardless of
whether pp1 actually satisfies its definition, that would seem weird, IMHO.
IMHO, it makes sense to drop each individual partition's {indexes, constraints,
column DEFAULTs, foreign keys, triggers, reloptions, custom tablespace, table
AM, per-column settings, security labels, ACLs, RLS policies}, and instead have
them inherit/depend on the parent's definitions.
The main reason I favor this approach: regrading the table's depent(indexes,
constraints etc) partitions being merged can differ from one another, so there's
no good justification for favoring any single partition's definitions over the
others.
For this case, ALTER TABLE MERGE PARTITIONS should let the new
partition use the partitioned table's generation expression, i think.
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-02 06:56 Zsolt Parragi <zsolt.parragi@percona.com>
parent: jian he <jian.universality@gmail.com>
0 siblings, 0 replies; 40+ messages in thread
From: Zsolt Parragi @ 2026-08-02 06:56 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: pgsql-bugs@lists.postgresql.org
> For ALTER TABLE pp MERGE PARTITIONS (pp1, pp2) INTO pp12, a CHECK constraint
> that exists only on pp2 surely should not apply to the new pp12. Otherwise, that
> constraint would also end up being enforced against pp1's data, regardless of
> whether pp1 actually satisfies its definition, that would seem weird, IMHO.
I agree, that's why I proposed failing the MERGE in this situation,
and to only allow it to proceed if pp1 and pp2 have he same
definition.
> For this case, ALTER TABLE MERGE PARTITIONS should let the new
> partition use the partitioned table's generation expression, i think.
My issue is that no other ALTER TABLE statement does that.
Consider this scenario:
CREATE FUNCTION f(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
CREATE TABLE t (i int, j int, k int, g int GENERATED ALWAYS AS (f(j*k)) STORED);
INSERT INTO t VALUES (1,1,1), (2,2,2), (3,3,3);
SELECT * FROM t;
i | j | k | g
---+---+---+----
1 | 1 | 1 | 2
2 | 2 | 2 | 8
3 | 3 | 3 | 18
CREATE OR REPLACE FUNCTION f(i int) RETURNS int IMMUTABLE LANGUAGE sql
AS 'SELECT i * 3'; -- change unrelated column that completely rewrites
the table
SELECT * FROM t;
i | j | k | g
---+---+---+----
1 | 1 | 1 | 2
2 | 2 | 2 | 8
3 | 3 | 3 | 18
So we get the same results: it kept the old values. Any unrelated
ALTER that doesn't change the generator expression or its dependents
will do this: keep the generated values as is, even if it rewrites the
relation file.
And what about ALTERs that try to change one of its dependent, and
would result in an unintuitive/hidden regeneration of the generated
column?
ALTER TABLE t ALTER COLUMN j TYPE smallint;
2026-08-02 07:38:13.368 WEST [1110699] ERROR: cannot alter type of a
column used by a generated column
2026-08-02 07:38:13.368 WEST [1110699] DETAIL: Column "j" is used by
generated column "g".
2026-08-02 07:38:13.368 WEST [1110699] STATEMENT: ALTER TABLE t
ALTER COLUMN j TYPE smallint;
ERROR: cannot alter type of a column used by a generated column
DETAIL: Column "j" is used by generated column "g"
It fails. And even if I alter the type of g directly:
ALTER TABLE t ALTER COLUMN g TYPE text;
ALTER TABLE
postgres=# SELECT * FROM t;
i | j | k | g
---+---+---+----
1 | 1 | 1 | 2
2 | 2 | 2 | 8
3 | 3 | 3 | 18
(3 rows)
It doesn't change. The only ALTER that causes it to change is ALTER
TABLE t COLUMN g SET EXPRESSION, which explicitly changes the
expression.
This would be the only ALTER TABLE command that behaves differently,
every other operation that would possibly change the generated
expression in a hidden way either reuses the old values (if it safely
can), or errors out (if it can't). It would be fine if this would be
called CREATE TABLE AS MERGE PARTITIONS and CREATE TABLE AS SPLIT
PARTITIONS, but it's called an ALTER TABLE, not a CREATE TABLE.
And also, think about SPLIT PARTITION: in the split scenario, what
reasoning do we have to "reuse the partitioned table's generation
expression"? We could very easily reuse the partition's definition,
and copy the current values, there's no complex logic to follow there.
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-03 19:26 Alexander Korotkov <aekorotkov@gmail.com>
parent: Zsolt Parragi <zsolt.parragi@percona.com>
1 sibling, 2 replies; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-03 19:26 UTC (permalink / raw)
To: Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: pgsql-bugs@lists.postgresql.org, pgsql-hackers <pgsql-hackers@postgresql.org>; jian he <jian.universality@gmail.com>
Hi, Zsolt!
Thank you for your valuable findings.
On Thu, Jul 23, 2026 at 1:59 PM Zsolt Parragi <zsolt.parragi@percona.com> wrote:
>
> I have multiple questions and potential issues with MERGE PARTITIONS /
> SPLIT PARTITIONS. I have ideas for fixing some of these problems, but
> not all of them, so I'd like to just discuss them before proposing
> anything specific:
>
> 1. Moved rows are inserted with plain heap inserts, so they are
> decoded as INSERTs into the new partition, with no matching deletes.
> This should either emit matching deletes before, or also skip the
> inserts, as the current behavior seems to break logical replication.
> The latter looks like a better solution to me, but I am not 100% sure
> about it.
MERGE/SPLIT partition(s) are DDL operations. We currently don't
support logical decoding of DDLs. So, I suppose we should just skip
logical decoding of inserts into new partition(s). 0001 patch
implements it with some tests and docs.
> 2. Should the new partition inherit direct publication membership from
> the partitions it replaces, especially for a split where this is
> clear? For a merge it's harder to argue about if the original
> partitions are different.
> Similarly what about replica identity?
The current approach of partition(s) MERGE/SPLIT is to create new
partition using the parent as the template without attempt to preserve
properties of previous partitions. That approach has been taken for
simplicity. If future we can add different behavior. But I see that
preserving replica identity can publication membership is essential to
continue streaming changes via partition root. 0002 patch implements
preserving these properties (simple case without identity using
index), and error out on mismatch.
> 3. What's the proper process to propagate a merge/split to a
> subscriber without data loss?
> For now let's assume that we implement the "no generated inserts"
> change I mentioned above, so that it at least works.
> * Everything in sync at the beginning
> * Merge command executed on publisher
> * An UPDATE targeting a merged row is executed on the publishers
> * Subscriber stops: can't execute the UPDATE
> * Subscriber needs a manual MERGE replay, table now exists locally,
> but it is not part of the subscription
> * Apply worker retries the update, sees the table, but it's not part
> of the subscription, so it drops the update
> * User runs REFRESH PUBLICATION with copy_table=false because the data
> is already there, the previous update was lost
>
> So seems like the working approach is either to TRUNCATE before
> REFRESH PUBLICATION, or to manually DROP/CREATE the partitions? Should
> this be documented somewhere?
After 0002, if you publish via partition root, it's not even
necessarily to apply any changes on replica. Replica could continue
use its partition schema. If publish from leaf partitions, then
replica should manually get similar partition(s) MERGE/SPLIT DDL.
> 4. Earlier I wrote that "the data didn't change"... but generated
> columns can silently change:
>
> CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
> PARTITION BY RANGE (id);
> CREATE TABLE t1 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
> ALTER TABLE t ATTACH PARTITION t1 FOR VALUES FROM (0) TO (10);
> CREATE TABLE t2 PARTITION OF t FOR VALUES FROM (10) TO (20);
>
> INSERT INTO t VALUES (3), (12);
> -- 3|300 12|24
> SELECT id, g FROM t ORDER BY id;
>
> ALTER TABLE t MERGE PARTITIONS (t1, t2) INTO t12;
> -- 3|6 12|24
> SELECT id, g FROM t ORDER BY id;
>
> Or another example:
>
> CREATE FUNCTION f(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
> CREATE TABLE t (id int, g int GENERATED ALWAYS AS (f(id)) STORED)
> PARTITION BY RANGE (id);
> CREATE TABLE t1 PARTITION OF t FOR VALUES FROM (0) TO (10);
> CREATE TABLE t2 PARTITION OF t FOR VALUES FROM (10) TO (20);
> INSERT INTO t VALUES (3), (12);
> -- 3|6 12|24
> SELECT id, g FROM t ORDER BY id;
>
> CREATE OR REPLACE FUNCTION f(i int) RETURNS int IMMUTABLE LANGUAGE sql
> AS 'SELECT i * 100';
>
> VACUUM FULL t; -- same result with unrelated rewriting alter
> -- 3|6 12|24
> SELECT id, g FROM t ORDER BY id;
>
> ALTER TABLE t MERGE PARTITIONS (t1, t2) INTO t12;
> -- 3|300 12|1200
> SELECT id, g FROM t ORDER BY id;
>
> This example is especially interesting because with VACUUM FULL or an
> unrelated rewriting ALTER TABLE, the data remains unchanged, so while
> this is a corner case, it can be surprising for users.
>
> The second example seems fixable to me, even if difficult, but I'm not
> sure what would be a good approach for the first, other than erroring
> out instead?
I agree this behavior is incorrect. The patch 0003 implements copying
values of generated columns "as is". The exclusion are expressions
containing tableoid (system column which will change after completion
of MERGE/SPLIT DDL). Reject this case for now. In future we may
implement recalculation of such generated columns and further
constraints re-validation (if needed).
> 5. In (2) I mentioned replication-related inheritance questions, but
> it is much more generic than that, many partition specific details get
> lost silently:
> * indexes
> * constraints
> * different DEFAULTs
> * foreign keys
> * triggers
> * reloptions
> * custom tablespace
> * table AM
> * per column settings
> * security labels
> * ACLs
> * RLS policies
>
> Shouldn't most of these copied into split partitions, and handled
> properly in merges (erroring out in non trivial cases?)
>
> Silently dropping them doesn't seem like a good behavior, as it can
> cause many different issues:
> * dropping foreign keys / checks can cause data integrity issues
> * dropping partition specific sequences can cause later inserts to
> fail or silently fall back to nulls/different values
> * probably many other scenarios I didn't think of
This was intended to keep patches simple enough for pg 19. That's
documented that we copy properties from parent, but don't copy from
previous partitions(s) [1][2]. We may implement other options in
further releases.
Links.
1. https://www.postgresql.org/docs/19/sql-altertable.html#SQL-ALTERTABLE-MERGE-PARTITIONS
2. https://www.postgresql.org/docs/19/sql-altertable.html#SQL-ALTERTABLE-SPLIT-PARTITION
------
Regards,
Alexander Korotkov
Supabase
Attachments:
[application/octet-stream] v1-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch (10.6K, ../../CAPpHfdtckoQ6-rBRt8=sz9mf-rYTW68f7Zj8d1XMyqNSYOZOeA@mail.gmail.com/2-v1-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch)
download | inline diff:
From 2a2a311cc61599f11667e53cf5d6e22403e9dc3c Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:08:36 +0200
Subject: [PATCH v1 1/3] Don't logically decode MERGE/SPLIT PARTITION row
movement
ALTER TABLE ... MERGE/SPLIT PARTITION relocates rows between partitions of the
same partitioned table by re-inserting them into the freshly created
partition(s), using plain heap inserts. Logical decoding emitted those as
INSERTs into the new partition with no matching DELETEs for the source rows,
which corrupts logical replication subscribers.
Pass TABLE_INSERT_NO_LOGICAL to the movers so the relocation is not decoded,
just as CLUSTER and VACUUM FULL already do for their rewrites. MERGE/SPLIT
PARTITION is a schema change that is not itself replicated, and the moved rows
still exist on subscribers, so suppressing the inserts keeps them consistent.
Document the behavior in the MERGE PARTITIONS and SPLIT PARTITION
commands descriptions, and add a test_decoding regression test.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
contrib/test_decoding/Makefile | 3 +-
.../expected/partition_merge_split.out | 56 +++++++++++++++++++
contrib/test_decoding/meson.build | 1 +
.../sql/partition_merge_split.sql | 34 +++++++++++
doc/src/sgml/ref/alter_table.sgml | 20 +++++++
src/backend/commands/tablecmds.c | 20 +++++--
6 files changed, 129 insertions(+), 5 deletions(-)
create mode 100644 contrib/test_decoding/expected/partition_merge_split.out
create mode 100644 contrib/test_decoding/sql/partition_merge_split.sql
diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile
index 0111124399a..ab90cd7fec2 100644
--- a/contrib/test_decoding/Makefile
+++ b/contrib/test_decoding/Makefile
@@ -5,7 +5,8 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin"
REGRESS = ddl xact rewrite toast permissions decoding_in_xact \
decoding_into_rel binary prepared replorigin time messages \
- repack spill slot truncate stream stats twophase twophase_stream
+ repack spill slot truncate stream stats twophase twophase_stream \
+ partition_merge_split
ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \
oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \
twophase_snapshot slot_creation_error catalog_change_snapshot \
diff --git a/contrib/test_decoding/expected/partition_merge_split.out b/contrib/test_decoding/expected/partition_merge_split.out
new file mode 100644
index 00000000000..63ec5af98d0
--- /dev/null
+++ b/contrib/test_decoding/expected/partition_merge_split.out
@@ -0,0 +1,56 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+ ?column?
+----------
+ init
+(1 row)
+
+INSERT INTO part VALUES (1), (11);
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ count
+-------
+ 4
+(1 row)
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+--------------------------------------------
+ BEGIN
+ table public.part_1: INSERT: id[integer]:2
+ COMMIT
+(3 rows)
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+ ?column?
+----------
+ stop
+(1 row)
+
+DROP TABLE part;
diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build
index ac655853d26..a504bc00794 100644
--- a/contrib/test_decoding/meson.build
+++ b/contrib/test_decoding/meson.build
@@ -42,6 +42,7 @@ tests += {
'stats',
'twophase',
'twophase_stream',
+ 'partition_merge_split',
],
'regress_args': [
'--temp-config', files('logical.conf'),
diff --git a/contrib/test_decoding/sql/partition_merge_split.sql b/contrib/test_decoding/sql/partition_merge_split.sql
new file mode 100644
index 00000000000..efdd6019ebd
--- /dev/null
+++ b/contrib/test_decoding/sql/partition_merge_split.sql
@@ -0,0 +1,34 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+
+INSERT INTO part VALUES (1), (11);
+
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+DROP TABLE part;
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..aaf4dfd111a 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,16 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Moving rows into the new partition does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... MERGE PARTITIONS</command> is a schema change and
+ is not itself replicated to logical replication subscribers; to reflect it
+ on a subscriber, run the equivalent command there, or drop and recreate
+ the affected partitions and refresh the subscription.
+ </para>
+
<note>
<para>
Merging partitions acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
@@ -1386,6 +1396,16 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Moving rows into the new partitions does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... SPLIT PARTITION</command> is a schema change and
+ is not itself replicated to logical replication subscribers; to reflect it
+ on a subscriber, run the equivalent command there, or drop and recreate
+ the affected partitions and refresh the subscription.
+ </para>
+
<note>
<para>
Split partition acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 6d4c457b820..0eb85c1be17 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23366,8 +23366,16 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
AlteredTableInfo *tab;
ListCell *ltab;
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Also suppress logical
+ * decoding of these inserts: merging partitions physically relocates rows
+ * within the same partitioned table, much like CLUSTER or VACUUM FULL.
+ * The relocation is not a user-level INSERT, and MERGE PARTITIONS is DDL
+ * that logical replication does not replicate anyway; emitting INSERTs
+ * for the moved rows (with no matching DELETEs for the source rows) would
+ * corrupt logical subscribers.
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
@@ -24034,8 +24042,12 @@ static void
SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
List *partlist, List *newPartRels)
{
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Suppress logical decoding
+ * of these inserts as well; see the matching comment in
+ * MergePartitionsMoveRows().
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
CommandId mycid;
EState *estate;
ListCell *listptr,
--
2.50.1 (Apple Git-155)
[application/octet-stream] v1-0002-Peserve-replica-identity-and-publications-in-MERG.patch (15.6K, ../../CAPpHfdtckoQ6-rBRt8=sz9mf-rYTW68f7Zj8d1XMyqNSYOZOeA@mail.gmail.com/3-v1-0002-Peserve-replica-identity-and-publications-in-MERG.patch)
download | inline diff:
From 0ce3ac739a0b6d8f8f6e3bdb8684b9e7f413c7df Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:14:57 +0200
Subject: [PATCH v1 2/3] Peserve replica identity and publications in
MERGE/SPLIT PARTITION(s)
The new partition(s) created by ALTER TABLE ... MERGE/SPLIT PARTITION are
built from the partitioned-table template, so they would default to
REPLICA IDENTITY DEFAULT and silently drop out of any publication that the
source partitions were directly part of, changing replication behavior
without a warning.
Carry a uniform, simply-representable replica identity (DEFAULT, FULL or
NOTHING) from the source partitions to the new partition(s). Raise an error
if the sources disagree, or use an index-based identity that cannot be
reproduced automatically, and let the user set it explicitly. Also refuse
the operation when any source partition is a direct member of a publication:
the new partition would otherwise leave it, and faithfully reproducing
per-relation column lists and row filters is ambiguous (especially when
several sources are merged). Publications that cover the partitioned root
continue to include the new partition, so those are unaffected.
Document this behavior and add a test coverage.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 27 ++++++
src/backend/commands/tablecmds.c | 85 +++++++++++++++++++
src/test/regress/expected/partition_merge.out | 33 +++++++
src/test/regress/expected/partition_split.out | 28 ++++++
src/test/regress/sql/partition_merge.sql | 28 ++++++
src/test/regress/sql/partition_split.sql | 22 +++++
6 files changed, 223 insertions(+)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index aaf4dfd111a..c034745365c 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ The new partition takes its replica identity from the merged partitions
+ when they all use the same simple setting
+ (<literal>DEFAULT</literal>, <literal>FULL</literal> or
+ <literal>NOTHING</literal>). If they use different settings, or use
+ <literal>REPLICA IDENTITY USING INDEX</literal>, the error is issued
+ and the command is aborted. Give the partitions being merged a uniform,
+ non-index replica identity before merging, and set a different replica
+ identity on the resulting partition afterwards if desired. Likewise, if
+ any of the partitions being merged is directly part of a publication, the
+ command is aborted; publish the partitioned table itself instead of the
+ individual partitions, or remove the partition from the publication before
+ merging.
+ </para>
+
<para>
Moving rows into the new partition does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
@@ -1396,6 +1411,18 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ The new partitions take their replica identity from the split partition,
+ unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
+ case the error is issued and the command is aborted. Give the partition
+ being split a non-index replica identity before splitting, and set a
+ different replica identity on the new partitions afterwards if desired.
+ Likewise, if the partition being split is directly part of a publication,
+ the command is rejected; publish the partitioned table itself instead of
+ the individual partitions, or remove the partition from the publication
+ before splitting.
+ </para>
+
<para>
Moving rows into the new partitions does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0eb85c1be17..5fd6173b533 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -49,6 +49,7 @@
#include "catalog/pg_opclass.h"
#include "catalog/pg_policy.h"
#include "catalog/pg_proc.h"
+#include "catalog/pg_publication.h"
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_rewrite.h"
#include "catalog/pg_statistic_ext.h"
@@ -23353,6 +23354,78 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
return newRel;
}
+/*
+ * transferPartitionReplicaIdentity: propagate the source partitions' replica
+ * identity to the new partition(s) created by MERGE/SPLIT, and refuse the
+ * operation for cases we cannot handle without silently changing replication
+ * behavior.
+ *
+ * The new partitions are built from the partitioned-table template and would
+ * otherwise default to REPLICA IDENTITY DEFAULT and drop out of any publication
+ * that the source partitions were directly part of. To avoid silent surprises:
+ *
+ * - A uniform, simply-representable replica identity (DEFAULT, FULL or
+ * NOTHING) is carried over to every new partition. If the sources disagree,
+ * or use an index-based identity (which cannot be reproduced on the new
+ * partition automatically), we raise an error and ask the user to set it.
+ *
+ * - If any source partition is a direct member of a publication, we refuse the
+ * operation: the new partition would silently leave the publication, and
+ * faithfully reproducing per-relation column lists and row filters is
+ * ambiguous (especially when several sources are merged). Publications that
+ * cover the partitioned root instead continue to include the new partition.
+ *
+ * 'sourceOids' lists the source partition OIDs (still present, not yet dropped);
+ * 'newPartRels' lists the new partition Relations (exclusively locked).
+ */
+static void
+transferPartitionReplicaIdentity(List *sourceOids, List *newPartRels)
+{
+ char ri_type = '\0';
+ bool ri_seen = false;
+
+ foreach_oid(srcOid, sourceOids)
+ {
+ Relation src = table_open(srcOid, NoLock);
+
+ if (GetRelationIncludedPublications(srcOid) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that is directly part of a publication",
+ RelationGetRelationName(src)),
+ errhint("Publish the partitioned table instead, or add the new partition to the publication after the operation."));
+
+ if (!ri_seen)
+ {
+ ri_type = src->rd_rel->relreplident;
+ ri_seen = true;
+ }
+ else if (ri_type != src->rd_rel->relreplident)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("partitions being merged have different replica identity settings"),
+ errhint("Set the replica identity of the new partition explicitly after the operation."));
+
+ table_close(src, NoLock);
+ }
+
+ /* Nothing to carry over, or the new partitions already match. */
+ if (!ri_seen || ri_type == REPLICA_IDENTITY_DEFAULT)
+ return;
+
+ if (ri_type == REPLICA_IDENTITY_INDEX)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot automatically transfer an index-based replica identity to the new partition"),
+ errhint("Set the replica identity of the new partition explicitly with ALTER TABLE ... REPLICA IDENTITY USING INDEX."));
+
+ /* Carry FULL / NOTHING over to each new partition. */
+ foreach_ptr(RelationData, newrel, newPartRels)
+ relation_mark_replica_identity(newrel, ri_type, InvalidOid, true);
+
+ CommandCounterIncrement();
+}
+
/*
* MergePartitionsMoveRows: scan partitions to be merged (mergingPartitions)
* of the partitioned table and move rows into the new partition
@@ -23903,6 +23976,12 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
Assert(OidIsValid(ownerId));
newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ /*
+ * Carry the source partitions' replica identity over to the new partition,
+ * and reject cases that would silently change replication behavior.
+ */
+ transferPartitionReplicaIdentity(mergingPartitions, list_make1(newPartRel));
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
@@ -24345,6 +24424,12 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
newPartRels = lappend(newPartRels, newPartRel);
}
+ /*
+ * Carry the split partition's replica identity over to the new partitions,
+ * and reject cases that would silently change replication behavior.
+ */
+ transferPartitionReplicaIdentity(list_make1_oid(splitRelOid), newPartRels);
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index ccda2b5843b..75d06beae19 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1167,6 +1167,39 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2';
+ relreplident
+--------------
+ f
+(1 row)
+
+DROP TABLE t;
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: partitions being merged have different replica identity settings
+HINT: Set the replica identity of the new partition explicitly after the operation.
+DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partition "tp_0_1" that is directly part of a publication
+HINT: Publish the partitioned table instead, or add the new partition to the publication after the operation.
+DROP PUBLICATION pub_merge;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 8e245563801..87374ca43ff 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1751,6 +1751,34 @@ SELECT relname, reltablespace FROM pg_class
tp_lo | 0
(2 rows)
+DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2') ORDER BY relname;
+ relname | relreplident
+---------+--------------
+ tp_0_1 | f
+ tp_1_2 | f
+(2 rows)
+
+DROP TABLE t;
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partition "tp_0_2" that is directly part of a publication
+HINT: Publish the partitioned table instead, or add the new partition to the publication after the operation.
+DROP PUBLICATION pub_split;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 80dc365b0ce..f714a1c64d5 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -839,6 +839,34 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2';
+DROP TABLE t;
+
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
+
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
+
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index ffd15e7f969..8734419e754 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1256,6 +1256,28 @@ SELECT relname, reltablespace FROM pg_class
WHERE relname IN ('tp_lo', 'tp_hi') ORDER BY relname;
DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2') ORDER BY relname;
+DROP TABLE t;
+
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
RESET search_path;
--
--
2.50.1 (Apple Git-155)
[application/octet-stream] v1-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch (24.0K, ../../CAPpHfdtckoQ6-rBRt8=sz9mf-rYTW68f7Zj8d1XMyqNSYOZOeA@mail.gmail.com/4-v1-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch)
download | inline diff:
From b3ccff2f1d371dd7f7b550b784454fed30431971 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:19:19 +0200
Subject: [PATCH v1 3/3] Don't recalculate generated columns during MERGE/SPLIT
PARTITION(S)
ALTER TABLE ... MERGE/SPLIT PARTITION unconditionally recomputed every stored
generated column of the moved rows using the partitioned table's generation
expression. When a leaf partition's generation expression -- or a function it
calls -- differed from the partitioned table's, this silently rewrote
already-stored values, and could even break constraints.
Relocating a row between partitions never changes a user column, so a stored
generated column defined over user columns yields the same value; move it as-is
instead of recomputing, as every other command preserves generated column
values. This alone removes the silent data changes and constraint violations
reported for such columns.
The one value that legitimately changes on the move is a stored generated
column whose expression references a system column (only tableoid is allowed
there). Recomputing it during the move is not safe: unlike a normal insert,
the row-movement path does not re-verify NOT NULL, foreign-key, or
generated-column-dependent CHECK constraints, so a recomputed value could
silently violate them. Rather than recompute without those checks, reject the
operation for such columns and let the user handle them explicitly.
As nothing is recomputed anymore, the machinery that evaluated generated
expressions during the row move is gone: createTableConstraints() no longer
records generated columns in AlteredTableInfo.newvals, and the two row-move
helpers are reduced to preparing and checking CHECK constraints (and renamed
buildPartitionCheckExprStates()/checkPartitionRowConstraints() accordingly).
Document the behavior and add regression coverage. Existing MERGE/SPLIT
tests that relied on recomputation now assert the rejection, and a
function-change test shows a plain generated column's value preserved.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 22 ++++
src/backend/commands/tablecmds.c | 114 ++++++++++--------
src/test/regress/expected/partition_merge.out | 58 +++++----
src/test/regress/expected/partition_split.out | 32 ++---
src/test/regress/sql/partition_merge.sql | 40 +++---
src/test/regress/sql/partition_split.sql | 20 +--
6 files changed, 159 insertions(+), 127 deletions(-)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index c034745365c..fdf18c264e3 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,17 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Stored generated columns keep their existing values; the merge does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ As an exception, if a stored generated column's expression references a
+ system column such as <structfield>tableoid</structfield> (whose value
+ would change when a row is moved to another partition), the command is
+ rejected, because the row-movement path cannot safely recompute the value
+ while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partition takes its replica identity from the merged partitions
when they all use the same simple setting
@@ -1411,6 +1422,17 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Stored generated columns keep their existing values; the split does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ As an exception, if a stored generated column's expression references a
+ system column such as <structfield>tableoid</structfield> (whose value
+ would change when a row is moved to another partition), the command is
+ rejected, because the row-movement path cannot safely recompute the value
+ while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partitions take their replica identity from the split partition,
unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 5fd6173b533..7b9dcf47207 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -22860,18 +22860,15 @@ GetAttributeStorage(Oid atttypid, const char *storagemode)
}
/*
- * buildExpressionExecutionStates: build the needed expression execution states
- * for new partition (newPartRel) checks and initialize expressions for
- * generated columns. All expressions should be created in "tab"
- * (AlteredTableInfo structure).
+ * buildPartitionCheckExprStates: build the expression execution states for the
+ * CHECK constraints of the new partition (newPartRel), stored in "tab"
+ * (AlteredTableInfo structure), so they can be verified against the relocated
+ * rows in checkPartitionRowConstraints().
*/
static void
-buildExpressionExecutionStates(AlteredTableInfo *tab, Relation newPartRel, EState *estate)
+buildPartitionCheckExprStates(AlteredTableInfo *tab, Relation newPartRel, EState *estate)
{
- /*
- * Build the needed expression execution states. Here, we expect only NOT
- * NULL and CHECK constraint.
- */
+ /* Here, we expect only NOT NULL and CHECK constraints. */
foreach_ptr(NewConstraint, con, tab->constraints)
{
switch (con->contype)
@@ -22892,36 +22889,22 @@ buildExpressionExecutionStates(AlteredTableInfo *tab, Relation newPartRel, EStat
(int) con->contype);
}
}
-
- /* Expression already planned in createTableConstraints */
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- ex->exprstate = ExecInitExpr((Expr *) ex->expr, NULL);
}
/*
- * evaluateGeneratedExpressionsAndCheckConstraints: evaluate any generated
- * expressions for "tab" (AlteredTableInfo structure) whose inputs come from
- * the new tuple (insertslot) of the new partition (newPartRel).
+ * checkPartitionRowConstraints: verify the new partition's CHECK constraints
+ * against a relocated row (insertslot). Stored generated columns are moved
+ * as-is (never recomputed; see createTableConstraints), so there are no
+ * generated expressions to evaluate here.
*/
static void
-evaluateGeneratedExpressionsAndCheckConstraints(AlteredTableInfo *tab,
- Relation newPartRel,
- TupleTableSlot *insertslot,
- ExprContext *econtext)
+checkPartitionRowConstraints(AlteredTableInfo *tab,
+ Relation newPartRel,
+ TupleTableSlot *insertslot,
+ ExprContext *econtext)
{
econtext->ecxt_scantuple = insertslot;
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- {
- if (!ex->is_generated)
- continue;
-
- insertslot->tts_values[ex->attnum - 1]
- = ExecEvalExpr(ex->exprstate,
- econtext,
- &insertslot->tts_isnull[ex->attnum - 1]);
- }
-
foreach_ptr(NewConstraint, con, tab->constraints)
{
switch (con->contype)
@@ -22995,11 +22978,29 @@ getAttributesList(Relation parent_rel)
return colList;
}
+/*
+ * expression_references_system_column: walker that returns true if the given
+ * expression references any system column (a Var with a negative attribute
+ * number, such as tableoid). Used to decide whether a stored generated column
+ * must be recomputed when a row is relocated between partitions.
+ */
+static bool
+expression_references_system_column(Node *node, void *context)
+{
+ if (node == NULL)
+ return false;
+ if (IsA(node, Var) && ((Var *) node)->varattno < 0)
+ return true;
+ return expression_tree_walker(node, expression_references_system_column,
+ context);
+}
+
/*
* createTableConstraints:
- * create check constraints, default values, and generated values for newRel
- * based on parent_rel. tab is pending-work queue for newRel, we may need it in
- * MergePartitionsMoveRows.
+ * create check constraints and column defaults (including generation
+ * expressions) for newRel based on parent_rel. The CHECK constraints are also
+ * recorded in "tab" so they can be re-verified against the relocated rows in
+ * MergePartitionsMoveRows()/SplitPartitionMoveRows().
*/
static void
createTableConstraints(List **wqueue, AlteredTableInfo *tab,
@@ -23045,7 +23046,6 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
bool found_whole_row;
AttrNumber num;
Node *def;
- NewColumnValue *newval;
if (attribute->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
this_default = build_generation_expression(parent_rel, attribute->attnum);
@@ -23067,19 +23067,31 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
StoreAttrDefault(newRel, num, def, false);
/*
- * Stored generated column expressions in parent_rel might
- * reference the tableoid. newRel, parent_rel tableoid clear is
- * not the same. If so, these stored generated columns require
- * recomputation for newRel within MergePartitionsMoveRows.
+ * Relocating a row between partitions never changes a user column,
+ * so a stored generated column defined over user columns keeps the
+ * same value; we move it as-is rather than recomputing it (which is
+ * what every other command does, and which avoids silently
+ * rewriting stored data when a leaf partition's generation
+ * expression, or a function it calls, differs from the partitioned
+ * table's).
+ *
+ * A stored generated column whose expression references a system
+ * column (in practice only tableoid is allowed there) is the one
+ * case whose value would legitimately change on the move. We can't
+ * recompute it safely: the row-movement path does not re-verify NOT
+ * NULL, foreign-key, or generated-column-dependent CHECK
+ * constraints the way a normal insert does, so a recomputed value
+ * could silently violate them. Rather than risk that, reject the
+ * operation and let the user handle such columns explicitly.
*/
- if (attribute->attgenerated == ATTRIBUTE_GENERATED_STORED)
- {
- newval = palloc0_object(NewColumnValue);
- newval->attnum = num;
- newval->expr = expression_planner((Expr *) def);
- newval->is_generated = (attribute->attgenerated != '\0');
- tab->newvals = lappend(tab->newvals, newval);
- }
+ if (attribute->attgenerated == ATTRIBUTE_GENERATED_STORED &&
+ expression_references_system_column(def, NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a stored generated column depends on a system column"),
+ errdetail("Column \"%s\" of relation \"%s\" is generated from an expression that references a system column such as tableoid.",
+ NameStr(attribute->attname),
+ RelationGetRelationName(parent_rel)));
}
}
@@ -23458,7 +23470,7 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
/* Generate the constraint and default execution states. */
estate = CreateExecutorState();
- buildExpressionExecutionStates(tab, newPartRel, estate);
+ buildPartitionCheckExprStates(tab, newPartRel, estate);
mycid = GetCurrentCommandId(true);
@@ -23548,7 +23560,7 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
* the new tuple. We assume these columns won't reference each
* other, so that there's no ordering dependency.
*/
- evaluateGeneratedExpressionsAndCheckConstraints(tab, newPartRel,
+ checkPartitionRowConstraints(tab, newPartRel,
insertslot, econtext);
/* Write the tuple out to the new relation. */
@@ -24154,7 +24166,7 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
/* Find the work queue entry for the new partition table: newPartRel. */
pc->tab = ATGetQueueEntry(wqueue, pc->partRel);
- buildExpressionExecutionStates(pc->tab, pc->partRel, estate);
+ buildPartitionCheckExprStates(pc->tab, pc->partRel, estate);
if (sps->bound->is_default)
{
@@ -24286,7 +24298,7 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
* new tuple. We assume these columns won't reference each other, so
* that there's no ordering dependency.
*/
- evaluateGeneratedExpressionsAndCheckConstraints(pc->tab, pc->partRel,
+ checkPartitionRowConstraints(pc->tab, pc->partRel,
insertslot, econtext);
/* Write the tuple out to the new relation. */
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 75d06beae19..ce87d2b139a 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -968,8 +968,8 @@ NOTICE: trigger(t) called: action = INSERT, when = BEFORE, level = ROW
SELECT tableoid::regclass, * FROM t ORDER BY b;
tableoid | i | t | b | d
----------+---+----------------+---+------------
- tp_0_1 | 0 | default_tp_0_1 | 1 | 01-01-2022
- tp_0_1 | 1 | default_tp_1_2 | 2 | 01-01-2022
+ tp_0_1 | 0 | default_tp_0_1 | 1 | 02-02-2022
+ tp_0_1 | 1 | default_tp_1_2 | 2 | 03-03-2022
tp_0_1 | 1 | default_t | 3 | 01-01-2022
(3 rows)
@@ -1030,31 +1030,19 @@ ERROR: insert or update on table "t_fk" violates foreign key constraint "t_fk_i
DETAIL: Key (i)=(2) is not present in table "t".
DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A stored generated column whose expression references a system column
+-- (tableoid) would have to be recomputed when a row is relocated to another
+-- partition; since the row-movement path cannot re-verify all constraints
+-- against a recomputed value, MERGE PARTITIONS is rejected instead. (A stored
+-- generated column over user columns only is fine: its value is preserved, as
+-- exercised above.)
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a stored generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
@@ -1168,6 +1156,30 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
(1 row)
DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+-----
+ 3 | 6
+ 5 | 500
+ 12 | 24
+(3 rows)
+
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 87374ca43ff..e2a5ba55124 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1620,39 +1620,25 @@ NOTICE: trigger(t) called: action = INSERT, when = BEFORE, level = ROW
SELECT tableoid::regclass, * FROM t ORDER BY tableoid::regclass::text COLLATE "C", b;
tableoid | i | t | b | d
----------+---+--------------+---+------------
- tp_0_1 | 0 | default_tp_x | 1 | 01-01-2022
- tp_x | 1 | default_tp_x | 2 | 01-01-2022
+ tp_0_1 | 0 | default_tp_x | 1 | 02-02-2022
+ tp_x | 1 | default_tp_x | 2 | 02-02-2022
tp_x | 1 | default_t | 3 | 01-01-2022
(3 rows)
DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A stored generated column whose expression references a system column
+-- (tableoid) would have to be recomputed when a row is relocated to another
+-- partition; since the row-movement path cannot re-verify all constraints
+-- against a recomputed value, SPLIT PARTITION is rejected instead.
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a stored generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
-- that out-of-line varlena attributes coming from the source partition
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index f714a1c64d5..1d44c4cbff4 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -736,25 +736,17 @@ DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A stored generated column whose expression references a system column
+-- (tableoid) would have to be recomputed when a row is relocated to another
+-- partition; since the row-movement path cannot re-verify all constraints
+-- against a recomputed value, MERGE PARTITIONS is rejected instead. (A stored
+-- generated column over user columns only is fine: its value is preserved, as
+-- exercised above.)
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
-
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
-
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
DROP TABLE t;
@@ -839,6 +831,24 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index 8734419e754..82a2fc50f1b 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1162,26 +1162,16 @@ DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A stored generated column whose expression references a system column
+-- (tableoid) would have to be recomputed when a row is relocated to another
+-- partition; since the row-movement path cannot re-verify all constraints
+-- against a recomputed value, SPLIT PARTITION is rejected instead.
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
-
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
-
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
--
2.50.1 (Apple Git-155)
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-05 01:27 jian he <jian.universality@gmail.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
1 sibling, 1 reply; 40+ messages in thread
From: jian he @ 2026-08-05 01:27 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-bugs@lists.postgresql.org, pgsql-hackers <pgsql-hackers@postgresql.org>
On Tue, Aug 4, 2026 at 3:26 AM Alexander Korotkov <aekorotkov@gmail.com> wrote:
>
> I agree this behavior is incorrect. The patch 0003 implements copying
> values of generated columns "as is". The exclusion are expressions
> containing tableoid (system column which will change after completion
> of MERGE/SPLIT DDL). Reject this case for now. In future we may
> implement recalculation of such generated columns and further
> constraints re-validation (if needed).
Copying the value of generated column "as is" can produce data that differs from
what the generated expression would compute if any merged partition's generation
expression differs from the partitioned table's.
For example:
DROP TABLE if exists t, tp_0_1, tp_0_2;
CREATE TABLE t (
id int,
g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL) PARTITION
BY RANGE (id);
CREATE TABLE tp_0_1 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (10);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (10) TO (20);
INSERT INTO t VALUES (1), (2);
ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
Now the generation expression for column g in tp_0_2 is ``NULLIF(id,
1) STORED``,
but the existing data (SELECT g FROM tp_0_2;) does not match what that
expression would compute.
This seems not OK?
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-05 17:02 Alexander Korotkov <aekorotkov@gmail.com>
parent: jian he <jian.universality@gmail.com>
0 siblings, 1 reply; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-05 17:02 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-bugs@lists.postgresql.org, pgsql-hackers <pgsql-hackers@postgresql.org>
On Wed, Aug 5, 2026 at 3:27 AM jian he <jian.universality@gmail.com> wrote:
>
> On Tue, Aug 4, 2026 at 3:26 AM Alexander Korotkov <aekorotkov@gmail.com> wrote:
> >
> > I agree this behavior is incorrect. The patch 0003 implements copying
> > values of generated columns "as is". The exclusion are expressions
> > containing tableoid (system column which will change after completion
> > of MERGE/SPLIT DDL). Reject this case for now. In future we may
> > implement recalculation of such generated columns and further
> > constraints re-validation (if needed).
>
> Copying the value of generated column "as is" can produce data that differs from
> what the generated expression would compute if any merged partition's generation
> expression differs from the partitioned table's.
>
> For example:
> DROP TABLE if exists t, tp_0_1, tp_0_2;
> CREATE TABLE t (
> id int,
> g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL) PARTITION
> BY RANGE (id);
> CREATE TABLE tp_0_1 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
> ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (10);
> CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (10) TO (20);
> INSERT INTO t VALUES (1), (2);
> ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
>
> Now the generation expression for column g in tp_0_2 is ``NULLIF(id,
> 1) STORED``,
> but the existing data (SELECT g FROM tp_0_2;) does not match what that
> expression would compute.
>
> This seems not OK?
Actually, this makes me uneasy. What about restricting SPLIT/MERGE to
the case when generated columns matching between source partitions and
parent. This is the only solution I consider appropriate at this
stage of development.
------
Regards,
Alexander Korotkov
Supabase
Attachments:
[application/octet-stream] v2-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch (36.0K, ../../CAPpHfduNEzAuAgNfOLYHLwGn6SjbDECKxq5rg9SCKnVd_h7nbg@mail.gmail.com/2-v2-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch)
download | inline diff:
From 1f6060370fa7167e73505344255706b7d5a727c9 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:19:19 +0200
Subject: [PATCH v2 3/3] Don't recalculate generated columns during MERGE/SPLIT
PARTITION(S)
ALTER TABLE ... MERGE/SPLIT PARTITION unconditionally recomputed every stored
generated column of the moved rows using the partitioned table's generation
expression. When a leaf partition's generation expression -- or a function it
calls -- differed from the partitioned table's, this silently rewrote
already-stored values, and could even break constraints.
Relocating a row between partitions never changes a user column, so a stored
generated column defined over user columns yields the same value; move it as-is
instead of recomputing, as every other command preserves generated column
values. This alone removes the silent data changes and constraint violations
reported for such columns.
Moving values as-is is only correct when the source partition's generation
expression matches the partitioned table's. A partition can carry a different
expression (ATTACH PARTITION requires the generated-column kind to match but
does not compare the expressions), in which case the moved-as-is value would not
match the new partition's generation expression -- silently storing data
inconsistent with the schema, and possibly violating NOT NULL, CHECK, or
foreign-key constraints. Reject MERGE/SPLIT in that case, in the new
checkPartitionGenExprMatchesParent().
The other value that legitimately changes on the move is a stored generated
column whose expression references a system column (only tableoid is allowed
there). Recomputing it during the move is not safe: unlike a normal insert,
the row-movement path does not re-verify NOT NULL, foreign-key, or
generated-column-dependent CHECK constraints, so a recomputed value could
silently violate them. Rather than recompute without those checks, reject the
operation for such columns and let the user handle them explicitly.
As nothing is recomputed anymore, the machinery that evaluated generated
expressions during the row move is gone: createTableConstraints() no longer
records generated columns in AlteredTableInfo.newvals, and the two row-move
helpers are reduced to preparing and checking CHECK constraints (and renamed
buildPartitionCheckExprStates()/checkPartitionRowConstraints() accordingly).
Document the behavior and add regression coverage for both rejections. Existing
MERGE/SPLIT tests that relied on recomputation now assert the rejection or use a
generation expression matching the partitioned table, and a function-change test
shows a plain generated column's value preserved.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 30 +++
src/backend/commands/tablecmds.c | 203 +++++++++++++-----
src/test/regress/expected/partition_merge.out | 75 ++++---
src/test/regress/expected/partition_split.out | 48 +++--
src/test/regress/sql/partition_merge.sql | 60 ++++--
src/test/regress/sql/partition_split.sql | 34 +--
6 files changed, 321 insertions(+), 129 deletions(-)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index c034745365c..e38bf501695 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Stored generated columns keep their existing values; the merge does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the merge is
+ rejected if a merged partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partition's
+ stored data inconsistent with its own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row-movement path cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partition takes its replica identity from the merged partitions
when they all use the same simple setting
@@ -1411,6 +1426,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Stored generated columns keep their existing values; the split does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the split is
+ rejected if the split partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partitions'
+ stored data inconsistent with their own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row-movement path cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partitions take their replica identity from the split partition,
unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 5fd6173b533..cbd64ce5c5d 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -22860,18 +22860,15 @@ GetAttributeStorage(Oid atttypid, const char *storagemode)
}
/*
- * buildExpressionExecutionStates: build the needed expression execution states
- * for new partition (newPartRel) checks and initialize expressions for
- * generated columns. All expressions should be created in "tab"
- * (AlteredTableInfo structure).
+ * buildPartitionCheckExprStates: build the expression execution states for the
+ * CHECK constraints of the new partition (newPartRel), stored in "tab"
+ * (AlteredTableInfo structure), so they can be verified against the relocated
+ * rows in checkPartitionRowConstraints().
*/
static void
-buildExpressionExecutionStates(AlteredTableInfo *tab, Relation newPartRel, EState *estate)
+buildPartitionCheckExprStates(AlteredTableInfo *tab, Relation newPartRel, EState *estate)
{
- /*
- * Build the needed expression execution states. Here, we expect only NOT
- * NULL and CHECK constraint.
- */
+ /* Here, we expect only NOT NULL and CHECK constraints. */
foreach_ptr(NewConstraint, con, tab->constraints)
{
switch (con->contype)
@@ -22892,36 +22889,22 @@ buildExpressionExecutionStates(AlteredTableInfo *tab, Relation newPartRel, EStat
(int) con->contype);
}
}
-
- /* Expression already planned in createTableConstraints */
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- ex->exprstate = ExecInitExpr((Expr *) ex->expr, NULL);
}
/*
- * evaluateGeneratedExpressionsAndCheckConstraints: evaluate any generated
- * expressions for "tab" (AlteredTableInfo structure) whose inputs come from
- * the new tuple (insertslot) of the new partition (newPartRel).
+ * checkPartitionRowConstraints: verify the new partition's CHECK constraints
+ * against a relocated row (insertslot). Stored generated columns are moved
+ * as-is (never recomputed; see createTableConstraints), so there are no
+ * generated expressions to evaluate here.
*/
static void
-evaluateGeneratedExpressionsAndCheckConstraints(AlteredTableInfo *tab,
- Relation newPartRel,
- TupleTableSlot *insertslot,
- ExprContext *econtext)
+checkPartitionRowConstraints(AlteredTableInfo *tab,
+ Relation newPartRel,
+ TupleTableSlot *insertslot,
+ ExprContext *econtext)
{
econtext->ecxt_scantuple = insertslot;
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- {
- if (!ex->is_generated)
- continue;
-
- insertslot->tts_values[ex->attnum - 1]
- = ExecEvalExpr(ex->exprstate,
- econtext,
- &insertslot->tts_isnull[ex->attnum - 1]);
- }
-
foreach_ptr(NewConstraint, con, tab->constraints)
{
switch (con->contype)
@@ -22995,11 +22978,101 @@ getAttributesList(Relation parent_rel)
return colList;
}
+/*
+ * expression_references_system_column: walker that returns true if the given
+ * expression references any system column (a Var with a negative attribute
+ * number, such as tableoid). Used to decide whether a stored generated column
+ * must be recomputed when a row is relocated between partitions.
+ */
+static bool
+expression_references_system_column(Node *node, void *context)
+{
+ if (node == NULL)
+ return false;
+ if (IsA(node, Var) && ((Var *) node)->varattno < 0)
+ return true;
+ return expression_tree_walker(node, expression_references_system_column,
+ context);
+}
+
+/*
+ * checkPartitionGenExprMatchesParent: reject MERGE/SPLIT PARTITION when a
+ * source partition has a generated column whose generation expression differs
+ * from the partitioned table's.
+ *
+ * MERGE/SPLIT PARTITION relocates rows into the new partition and copies stored
+ * generated columns as-is rather than recomputing them (see
+ * createTableConstraints()). Since the new partition is created from the
+ * partitioned table as a template, moving values as-is is only correct when the
+ * source partition's generation expression matches the partitioned table's.
+ * Otherwise the moved value would not match the new partition's generation
+ * expression, silently storing data inconsistent with the schema and possibly
+ * violating NOT NULL, CHECK, or foreign-key constraints.
+ *
+ * A partition can end up with a generation expression different from the
+ * partitioned table's via ATTACH PARTITION, which requires the generated-column
+ * kind to match but does not compare the expressions themselves (see
+ * MergeAttributesIntoExisting()).
+ */
+static void
+checkPartitionGenExprMatchesParent(Relation parent_rel, Relation partRel)
+{
+ TupleDesc parentDesc = RelationGetDescr(parent_rel);
+ TupleConstr *constr = parentDesc->constr;
+ AttrMap *attmap = NULL;
+
+ /* Nothing to compare if the partitioned table has no generated columns. */
+ if (constr == NULL ||
+ !(constr->has_generated_stored || constr->has_generated_virtual))
+ return;
+
+ for (AttrNumber parent_attno = 1; parent_attno <= parentDesc->natts;
+ parent_attno++)
+ {
+ Form_pg_attribute pattr = TupleDescAttr(parentDesc, parent_attno - 1);
+ AttrNumber child_attno;
+ Node *parentExpr;
+ Node *childExpr;
+ bool found_whole_row;
+
+ if (pattr->attisdropped || pattr->attgenerated == '\0')
+ continue;
+
+ /*
+ * Column names match between a partitioned table and its partitions,
+ * and so does the generated-column kind; only the expression can differ
+ * (all enforced/allowed by MergeAttributesIntoExisting()).
+ */
+ child_attno = get_attnum(RelationGetRelid(partRel), NameStr(pattr->attname));
+ Assert(child_attno != InvalidAttrNumber);
+
+ parentExpr = build_generation_expression(parent_rel, parent_attno);
+ childExpr = build_generation_expression(partRel, child_attno);
+
+ /* Rewrite the partition's expression into the parent's numbering. */
+ if (attmap == NULL)
+ attmap = build_attrmap_by_name(parentDesc,
+ RelationGetDescr(partRel), false);
+ childExpr = map_variable_attnos(childExpr, 1, 0, attmap,
+ InvalidOid, &found_whole_row);
+
+ if (found_whole_row || !equal(parentExpr, childExpr))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a partition's generation expression differs from the partitioned table"),
+ errdetail("Generated column \"%s\" of partition \"%s\" has a generation expression different from table \"%s\".",
+ NameStr(pattr->attname),
+ RelationGetRelationName(partRel),
+ RelationGetRelationName(parent_rel)));
+ }
+}
+
/*
* createTableConstraints:
- * create check constraints, default values, and generated values for newRel
- * based on parent_rel. tab is pending-work queue for newRel, we may need it in
- * MergePartitionsMoveRows.
+ * create check constraints and column defaults (including generation
+ * expressions) for newRel based on parent_rel. The CHECK constraints are also
+ * recorded in "tab" so they can be re-verified against the relocated rows in
+ * MergePartitionsMoveRows()/SplitPartitionMoveRows().
*/
static void
createTableConstraints(List **wqueue, AlteredTableInfo *tab,
@@ -23045,7 +23118,6 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
bool found_whole_row;
AttrNumber num;
Node *def;
- NewColumnValue *newval;
if (attribute->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
this_default = build_generation_expression(parent_rel, attribute->attnum);
@@ -23067,19 +23139,32 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
StoreAttrDefault(newRel, num, def, false);
/*
- * Stored generated column expressions in parent_rel might
- * reference the tableoid. newRel, parent_rel tableoid clear is
- * not the same. If so, these stored generated columns require
- * recomputation for newRel within MergePartitionsMoveRows.
+ * Relocating a row between partitions never changes a user column,
+ * so a stored generated column defined over user columns keeps the
+ * same value; we move it as-is rather than recomputing it, which is
+ * what every other command does. (A source partition whose
+ * generation expression differs from the partitioned table's has
+ * already been rejected by checkPartitionGenExprMatchesParent();
+ * moving as-is here also avoids silently rewriting stored data when
+ * a function the expression calls has since been redefined.)
+ *
+ * A stored generated column whose expression references a system
+ * column (in practice only tableoid is allowed there) is the one
+ * case whose value would legitimately change on the move. We can't
+ * recompute it safely: the row-movement path does not re-verify NOT
+ * NULL, foreign-key, or generated-column-dependent CHECK
+ * constraints the way a normal insert does, so a recomputed value
+ * could silently violate them. Rather than risk that, reject the
+ * operation and let the user handle such columns explicitly.
*/
- if (attribute->attgenerated == ATTRIBUTE_GENERATED_STORED)
- {
- newval = palloc0_object(NewColumnValue);
- newval->attnum = num;
- newval->expr = expression_planner((Expr *) def);
- newval->is_generated = (attribute->attgenerated != '\0');
- tab->newvals = lappend(tab->newvals, newval);
- }
+ if (attribute->attgenerated == ATTRIBUTE_GENERATED_STORED &&
+ expression_references_system_column(def, NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a stored generated column depends on a system column"),
+ errdetail("Column \"%s\" of relation \"%s\" is generated from an expression that references a system column such as tableoid.",
+ NameStr(attribute->attname),
+ RelationGetRelationName(parent_rel)));
}
}
@@ -23458,7 +23543,7 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
/* Generate the constraint and default execution states. */
estate = CreateExecutorState();
- buildExpressionExecutionStates(tab, newPartRel, estate);
+ buildPartitionCheckExprStates(tab, newPartRel, estate);
mycid = GetCurrentCommandId(true);
@@ -23548,7 +23633,7 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
* the new tuple. We assume these columns won't reference each
* other, so that there's no ordering dependency.
*/
- evaluateGeneratedExpressionsAndCheckConstraints(tab, newPartRel,
+ checkPartitionRowConstraints(tab, newPartRel,
insertslot, econtext);
/* Write the tuple out to the new relation. */
@@ -23875,6 +23960,14 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
else
ownerId = mergingPartition->rd_rel->relowner;
+ /*
+ * The new partition inherits the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data.
+ */
+ checkPartitionGenExprMatchesParent(rel, mergingPartition);
+
/* Store the next merging partition into the list. */
mergingPartitions = lappend_oid(mergingPartitions,
RelationGetRelid(mergingPartition));
@@ -24154,7 +24247,7 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
/* Find the work queue entry for the new partition table: newPartRel. */
pc->tab = ATGetQueueEntry(wqueue, pc->partRel);
- buildExpressionExecutionStates(pc->tab, pc->partRel, estate);
+ buildPartitionCheckExprStates(pc->tab, pc->partRel, estate);
if (sps->bound->is_default)
{
@@ -24286,7 +24379,7 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
* new tuple. We assume these columns won't reference each other, so
* that there's no ordering dependency.
*/
- evaluateGeneratedExpressionsAndCheckConstraints(pc->tab, pc->partRel,
+ checkPartitionRowConstraints(pc->tab, pc->partRel,
insertslot, econtext);
/* Write the tuple out to the new relation. */
@@ -24344,6 +24437,14 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
splitRelOid = RelationGetRelid(splitRel);
+ /*
+ * The new partitions inherit the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a split partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data.
+ */
+ checkPartitionGenExprMatchesParent(rel, splitRel);
+
/* Check descriptions of new partitions. */
foreach_node(SinglePartitionSpec, sps, cmd->partlist)
{
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 75d06beae19..14cfe8aebeb 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -887,14 +887,14 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -926,7 +926,7 @@ CREATE TRIGGER tp_1_2_before_insert_row_trigger BEFORE INSERT ON tp_1_2 FOR EACH
i | integer | | not null | | plain | | | tp_0_1.i
t | text | | | 'default_tp_0_1'::text | main | | |
b | bigint | | not null | | plain | | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | | |
Partition of: t FOR VALUES FROM (0) TO (1)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 1))
Check constraints:
@@ -1030,31 +1030,19 @@ ERROR: insert or update on table "t_fk" violates foreign key constraint "t_fk_i
DETAIL: Key (i)=(2) is not present in table "t".
DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A stored generated column whose expression references a system column
+-- (tableoid) would have to be recomputed when a row is relocated to another
+-- partition; since the row-movement path cannot re-verify all constraints
+-- against a recomputed value, MERGE PARTITIONS is rejected instead. (A stored
+-- generated column over user columns only is fine: its value is preserved, as
+-- exercised above.)
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a stored generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
@@ -1167,6 +1155,45 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+-----
+ 3 | 6
+ 5 | 500
+ 12 | 24
+(3 rows)
+
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_10" has a generation expression different from table "t".
DROP TABLE t;
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 87374ca43ff..f484045983a 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1547,7 +1547,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -1576,7 +1576,7 @@ CREATE TRIGGER tp_x_before_insert_row_trigger BEFORE INSERT ON tp_x FOR EACH ROW
i | integer | | not null | | plain | | tp_x.i
t | text | | | 'default_tp_x'::text | main | |
b | bigint | | not null | | plain | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | |
Partition of: t FOR VALUES FROM (0) TO (2)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 2))
Check constraints:
@@ -1627,32 +1627,34 @@ SELECT tableoid::regclass, * FROM t ORDER BY tableoid::regclass::text COLLATE "C
DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A stored generated column whose expression references a system column
+-- (tableoid) would have to be recomputed when a row is relocated to another
+-- partition; since the row-movement path cannot re-verify all constraints
+-- against a recomputed value, SPLIT PARTITION is rejected instead.
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a stored generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_20" has a generation expression different from table "t".
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
-- that out-of-line varlena attributes coming from the source partition
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index f714a1c64d5..ab45df61bb5 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -649,7 +649,7 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
@@ -657,7 +657,7 @@ CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
@@ -736,25 +736,17 @@ DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A stored generated column whose expression references a system column
+-- (tableoid) would have to be recomputed when a row is relocated to another
+-- partition; since the row-movement path cannot re-verify all constraints
+-- against a recomputed value, MERGE PARTITIONS is rejected instead. (A stored
+-- generated column over user columns only is fine: its value is preserved, as
+-- exercised above.)
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
-
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
-
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
DROP TABLE t;
@@ -839,6 +831,40 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+
+
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+DROP TABLE t;
+
+
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index 8734419e754..e100c8fd27c 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1122,7 +1122,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
@@ -1162,26 +1162,32 @@ DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A stored generated column whose expression references a system column
+-- (tableoid) would have to be recomputed when a row is relocated to another
+-- partition; since the row-movement path cannot re-verify all constraints
+-- against a recomputed value, SPLIT PARTITION is rejected instead.
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
-
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
--
2.50.1 (Apple Git-155)
[application/octet-stream] v2-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch (10.6K, ../../CAPpHfduNEzAuAgNfOLYHLwGn6SjbDECKxq5rg9SCKnVd_h7nbg@mail.gmail.com/3-v2-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch)
download | inline diff:
From 2a2a311cc61599f11667e53cf5d6e22403e9dc3c Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:08:36 +0200
Subject: [PATCH v2 1/3] Don't logically decode MERGE/SPLIT PARTITION row
movement
ALTER TABLE ... MERGE/SPLIT PARTITION relocates rows between partitions of the
same partitioned table by re-inserting them into the freshly created
partition(s), using plain heap inserts. Logical decoding emitted those as
INSERTs into the new partition with no matching DELETEs for the source rows,
which corrupts logical replication subscribers.
Pass TABLE_INSERT_NO_LOGICAL to the movers so the relocation is not decoded,
just as CLUSTER and VACUUM FULL already do for their rewrites. MERGE/SPLIT
PARTITION is a schema change that is not itself replicated, and the moved rows
still exist on subscribers, so suppressing the inserts keeps them consistent.
Document the behavior in the MERGE PARTITIONS and SPLIT PARTITION
commands descriptions, and add a test_decoding regression test.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
contrib/test_decoding/Makefile | 3 +-
.../expected/partition_merge_split.out | 56 +++++++++++++++++++
contrib/test_decoding/meson.build | 1 +
.../sql/partition_merge_split.sql | 34 +++++++++++
doc/src/sgml/ref/alter_table.sgml | 20 +++++++
src/backend/commands/tablecmds.c | 20 +++++--
6 files changed, 129 insertions(+), 5 deletions(-)
create mode 100644 contrib/test_decoding/expected/partition_merge_split.out
create mode 100644 contrib/test_decoding/sql/partition_merge_split.sql
diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile
index 0111124399a..ab90cd7fec2 100644
--- a/contrib/test_decoding/Makefile
+++ b/contrib/test_decoding/Makefile
@@ -5,7 +5,8 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin"
REGRESS = ddl xact rewrite toast permissions decoding_in_xact \
decoding_into_rel binary prepared replorigin time messages \
- repack spill slot truncate stream stats twophase twophase_stream
+ repack spill slot truncate stream stats twophase twophase_stream \
+ partition_merge_split
ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \
oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \
twophase_snapshot slot_creation_error catalog_change_snapshot \
diff --git a/contrib/test_decoding/expected/partition_merge_split.out b/contrib/test_decoding/expected/partition_merge_split.out
new file mode 100644
index 00000000000..63ec5af98d0
--- /dev/null
+++ b/contrib/test_decoding/expected/partition_merge_split.out
@@ -0,0 +1,56 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+ ?column?
+----------
+ init
+(1 row)
+
+INSERT INTO part VALUES (1), (11);
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ count
+-------
+ 4
+(1 row)
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+--------------------------------------------
+ BEGIN
+ table public.part_1: INSERT: id[integer]:2
+ COMMIT
+(3 rows)
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+ ?column?
+----------
+ stop
+(1 row)
+
+DROP TABLE part;
diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build
index ac655853d26..a504bc00794 100644
--- a/contrib/test_decoding/meson.build
+++ b/contrib/test_decoding/meson.build
@@ -42,6 +42,7 @@ tests += {
'stats',
'twophase',
'twophase_stream',
+ 'partition_merge_split',
],
'regress_args': [
'--temp-config', files('logical.conf'),
diff --git a/contrib/test_decoding/sql/partition_merge_split.sql b/contrib/test_decoding/sql/partition_merge_split.sql
new file mode 100644
index 00000000000..efdd6019ebd
--- /dev/null
+++ b/contrib/test_decoding/sql/partition_merge_split.sql
@@ -0,0 +1,34 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+
+INSERT INTO part VALUES (1), (11);
+
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+DROP TABLE part;
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..aaf4dfd111a 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,16 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Moving rows into the new partition does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... MERGE PARTITIONS</command> is a schema change and
+ is not itself replicated to logical replication subscribers; to reflect it
+ on a subscriber, run the equivalent command there, or drop and recreate
+ the affected partitions and refresh the subscription.
+ </para>
+
<note>
<para>
Merging partitions acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
@@ -1386,6 +1396,16 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Moving rows into the new partitions does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... SPLIT PARTITION</command> is a schema change and
+ is not itself replicated to logical replication subscribers; to reflect it
+ on a subscriber, run the equivalent command there, or drop and recreate
+ the affected partitions and refresh the subscription.
+ </para>
+
<note>
<para>
Split partition acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 6d4c457b820..0eb85c1be17 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23366,8 +23366,16 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
AlteredTableInfo *tab;
ListCell *ltab;
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Also suppress logical
+ * decoding of these inserts: merging partitions physically relocates rows
+ * within the same partitioned table, much like CLUSTER or VACUUM FULL.
+ * The relocation is not a user-level INSERT, and MERGE PARTITIONS is DDL
+ * that logical replication does not replicate anyway; emitting INSERTs
+ * for the moved rows (with no matching DELETEs for the source rows) would
+ * corrupt logical subscribers.
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
@@ -24034,8 +24042,12 @@ static void
SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
List *partlist, List *newPartRels)
{
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Suppress logical decoding
+ * of these inserts as well; see the matching comment in
+ * MergePartitionsMoveRows().
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
CommandId mycid;
EState *estate;
ListCell *listptr,
--
2.50.1 (Apple Git-155)
[application/octet-stream] v2-0002-Peserve-replica-identity-and-publications-in-MERG.patch (15.6K, ../../CAPpHfduNEzAuAgNfOLYHLwGn6SjbDECKxq5rg9SCKnVd_h7nbg@mail.gmail.com/4-v2-0002-Peserve-replica-identity-and-publications-in-MERG.patch)
download | inline diff:
From 0ce3ac739a0b6d8f8f6e3bdb8684b9e7f413c7df Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:14:57 +0200
Subject: [PATCH v2 2/3] Peserve replica identity and publications in
MERGE/SPLIT PARTITION(s)
The new partition(s) created by ALTER TABLE ... MERGE/SPLIT PARTITION are
built from the partitioned-table template, so they would default to
REPLICA IDENTITY DEFAULT and silently drop out of any publication that the
source partitions were directly part of, changing replication behavior
without a warning.
Carry a uniform, simply-representable replica identity (DEFAULT, FULL or
NOTHING) from the source partitions to the new partition(s). Raise an error
if the sources disagree, or use an index-based identity that cannot be
reproduced automatically, and let the user set it explicitly. Also refuse
the operation when any source partition is a direct member of a publication:
the new partition would otherwise leave it, and faithfully reproducing
per-relation column lists and row filters is ambiguous (especially when
several sources are merged). Publications that cover the partitioned root
continue to include the new partition, so those are unaffected.
Document this behavior and add a test coverage.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 27 ++++++
src/backend/commands/tablecmds.c | 85 +++++++++++++++++++
src/test/regress/expected/partition_merge.out | 33 +++++++
src/test/regress/expected/partition_split.out | 28 ++++++
src/test/regress/sql/partition_merge.sql | 28 ++++++
src/test/regress/sql/partition_split.sql | 22 +++++
6 files changed, 223 insertions(+)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index aaf4dfd111a..c034745365c 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ The new partition takes its replica identity from the merged partitions
+ when they all use the same simple setting
+ (<literal>DEFAULT</literal>, <literal>FULL</literal> or
+ <literal>NOTHING</literal>). If they use different settings, or use
+ <literal>REPLICA IDENTITY USING INDEX</literal>, the error is issued
+ and the command is aborted. Give the partitions being merged a uniform,
+ non-index replica identity before merging, and set a different replica
+ identity on the resulting partition afterwards if desired. Likewise, if
+ any of the partitions being merged is directly part of a publication, the
+ command is aborted; publish the partitioned table itself instead of the
+ individual partitions, or remove the partition from the publication before
+ merging.
+ </para>
+
<para>
Moving rows into the new partition does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
@@ -1396,6 +1411,18 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ The new partitions take their replica identity from the split partition,
+ unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
+ case the error is issued and the command is aborted. Give the partition
+ being split a non-index replica identity before splitting, and set a
+ different replica identity on the new partitions afterwards if desired.
+ Likewise, if the partition being split is directly part of a publication,
+ the command is rejected; publish the partitioned table itself instead of
+ the individual partitions, or remove the partition from the publication
+ before splitting.
+ </para>
+
<para>
Moving rows into the new partitions does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0eb85c1be17..5fd6173b533 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -49,6 +49,7 @@
#include "catalog/pg_opclass.h"
#include "catalog/pg_policy.h"
#include "catalog/pg_proc.h"
+#include "catalog/pg_publication.h"
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_rewrite.h"
#include "catalog/pg_statistic_ext.h"
@@ -23353,6 +23354,78 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
return newRel;
}
+/*
+ * transferPartitionReplicaIdentity: propagate the source partitions' replica
+ * identity to the new partition(s) created by MERGE/SPLIT, and refuse the
+ * operation for cases we cannot handle without silently changing replication
+ * behavior.
+ *
+ * The new partitions are built from the partitioned-table template and would
+ * otherwise default to REPLICA IDENTITY DEFAULT and drop out of any publication
+ * that the source partitions were directly part of. To avoid silent surprises:
+ *
+ * - A uniform, simply-representable replica identity (DEFAULT, FULL or
+ * NOTHING) is carried over to every new partition. If the sources disagree,
+ * or use an index-based identity (which cannot be reproduced on the new
+ * partition automatically), we raise an error and ask the user to set it.
+ *
+ * - If any source partition is a direct member of a publication, we refuse the
+ * operation: the new partition would silently leave the publication, and
+ * faithfully reproducing per-relation column lists and row filters is
+ * ambiguous (especially when several sources are merged). Publications that
+ * cover the partitioned root instead continue to include the new partition.
+ *
+ * 'sourceOids' lists the source partition OIDs (still present, not yet dropped);
+ * 'newPartRels' lists the new partition Relations (exclusively locked).
+ */
+static void
+transferPartitionReplicaIdentity(List *sourceOids, List *newPartRels)
+{
+ char ri_type = '\0';
+ bool ri_seen = false;
+
+ foreach_oid(srcOid, sourceOids)
+ {
+ Relation src = table_open(srcOid, NoLock);
+
+ if (GetRelationIncludedPublications(srcOid) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that is directly part of a publication",
+ RelationGetRelationName(src)),
+ errhint("Publish the partitioned table instead, or add the new partition to the publication after the operation."));
+
+ if (!ri_seen)
+ {
+ ri_type = src->rd_rel->relreplident;
+ ri_seen = true;
+ }
+ else if (ri_type != src->rd_rel->relreplident)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("partitions being merged have different replica identity settings"),
+ errhint("Set the replica identity of the new partition explicitly after the operation."));
+
+ table_close(src, NoLock);
+ }
+
+ /* Nothing to carry over, or the new partitions already match. */
+ if (!ri_seen || ri_type == REPLICA_IDENTITY_DEFAULT)
+ return;
+
+ if (ri_type == REPLICA_IDENTITY_INDEX)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot automatically transfer an index-based replica identity to the new partition"),
+ errhint("Set the replica identity of the new partition explicitly with ALTER TABLE ... REPLICA IDENTITY USING INDEX."));
+
+ /* Carry FULL / NOTHING over to each new partition. */
+ foreach_ptr(RelationData, newrel, newPartRels)
+ relation_mark_replica_identity(newrel, ri_type, InvalidOid, true);
+
+ CommandCounterIncrement();
+}
+
/*
* MergePartitionsMoveRows: scan partitions to be merged (mergingPartitions)
* of the partitioned table and move rows into the new partition
@@ -23903,6 +23976,12 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
Assert(OidIsValid(ownerId));
newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ /*
+ * Carry the source partitions' replica identity over to the new partition,
+ * and reject cases that would silently change replication behavior.
+ */
+ transferPartitionReplicaIdentity(mergingPartitions, list_make1(newPartRel));
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
@@ -24345,6 +24424,12 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
newPartRels = lappend(newPartRels, newPartRel);
}
+ /*
+ * Carry the split partition's replica identity over to the new partitions,
+ * and reject cases that would silently change replication behavior.
+ */
+ transferPartitionReplicaIdentity(list_make1_oid(splitRelOid), newPartRels);
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index ccda2b5843b..75d06beae19 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1167,6 +1167,39 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2';
+ relreplident
+--------------
+ f
+(1 row)
+
+DROP TABLE t;
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: partitions being merged have different replica identity settings
+HINT: Set the replica identity of the new partition explicitly after the operation.
+DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partition "tp_0_1" that is directly part of a publication
+HINT: Publish the partitioned table instead, or add the new partition to the publication after the operation.
+DROP PUBLICATION pub_merge;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 8e245563801..87374ca43ff 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1751,6 +1751,34 @@ SELECT relname, reltablespace FROM pg_class
tp_lo | 0
(2 rows)
+DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2') ORDER BY relname;
+ relname | relreplident
+---------+--------------
+ tp_0_1 | f
+ tp_1_2 | f
+(2 rows)
+
+DROP TABLE t;
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partition "tp_0_2" that is directly part of a publication
+HINT: Publish the partitioned table instead, or add the new partition to the publication after the operation.
+DROP PUBLICATION pub_split;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 80dc365b0ce..f714a1c64d5 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -839,6 +839,34 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2';
+DROP TABLE t;
+
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
+
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
+
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index ffd15e7f969..8734419e754 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1256,6 +1256,28 @@ SELECT relname, reltablespace FROM pg_class
WHERE relname IN ('tp_lo', 'tp_hi') ORDER BY relname;
DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2') ORDER BY relname;
+DROP TABLE t;
+
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
RESET search_path;
--
--
2.50.1 (Apple Git-155)
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-06 03:45 jian he <jian.universality@gmail.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
0 siblings, 1 reply; 40+ messages in thread
From: jian he @ 2026-08-06 03:45 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-bugs@lists.postgresql.org, pgsql-hackers <pgsql-hackers@postgresql.org>
On Thu, Aug 6, 2026 at 1:02 AM Alexander Korotkov <aekorotkov@gmail.com> wrote:
>
> Actually, this makes me uneasy. What about restricting SPLIT/MERGE to
> the case when generated columns matching between source partitions and
> parent. This is the only solution I consider appropriate at this
> stage of development.
>
drop table if exists x;
CREATE TABLE x (id int, g int GENERATED ALWAYS AS (NULLIF(tableoid,
18470)) NOT NULL) partition by range(id);
CREATE TABLE x1 PARTITION OF x FOR VALUES FROM (10) TO (20);
CREATE TABLE x2 PARTITION OF x FOR VALUES FROM (20) TO (30);
ALTER TABLE x MERGE PARTITIONS (x1, x2) INTO x12;
It's possible that the new table x12's tableoid is 18470, and
MergePartitionsMoveRows, checkPartitionRowConstraints did nothing
about it.
So at the end of checkPartitionGenExprMatchesParent,
we can use expression_references_system_column(generation_expr) to
guard against such corner case, regardless of the generated column
kind.
Please check the attached diff to address this issue.
expression_references_system_column is a useful helper function that
can be reused in multiple places, so I also added its declaration.
In our context, we can use it in createTableConstraints, which is
better than pull_varattnos i think.
I also did pgindent on tablecmds.c
(I didn't review v2-0001, v2-0002).
Attachments:
[application/octet-stream] v2-0001-misc-fix-for-Don-t-recalculate-generated-columns-during-MERGE-S.nocfbot (14.6K, ../../CACJufxEpBFLJMfSw1oOG8+yYRN=7G1Ue2zFZwFh7U9JLk0kqNQ@mail.gmail.com/2-v2-0001-misc-fix-for-Don-t-recalculate-generated-columns-during-MERGE-S.nocfbot)
download
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-06 22:59 Zsolt Parragi <zsolt.parragi@percona.com>
parent: jian he <jian.universality@gmail.com>
0 siblings, 1 reply; 40+ messages in thread
From: Zsolt Parragi @ 2026-08-06 22:59 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, Alexander Korotkov <aekorotkov@gmail.com>
> Copying the value of generated column "as is" can produce data that differs from
> what the generated expression would compute if any merged partition's generation
> expression differs from the partitioned table's.
I think this would be probably fine, as we can get the same effect by
replacing a function used by the expression, a preexisting condition
for many existing cases. But I do agree that requiring the same
expression is a better approach.
Also, not directly related to this patch, but now that I looked into
this, I can still use tableoids for check constraints with a text
cast:
CREATE TABLE t (i int) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
INSERT INTO t VALUES (0),(1);
ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; --
SUCCESS, but should ERROR instead?
And another question I realized while looking at differences to other
rewrite operators: currently merge/split doesn't fire a rewrite event
trigger, but shouldn't it?
For the replication changes: shouldn't we also restrict schema
changes? `TABLES IN SCHEMA` can still be problematic if the parent and
the specific partitions are in different schemas, they either get
published or unpublished.
+ <command>ALTER TABLE ... MERGE PARTITIONS</command> is a schema
change and
+ is not itself replicated to logical replication subscribers; to
reflect it
+ on a subscriber, run the equivalent command there, or drop and recreate
+ the affected partitions and refresh the subscription.
I think this still results in my original (3) data loss scenario, so I
don't think it's a good idea to recommend it.
For example if we MERGE + UPDATE/INSERT on the publisher, the
subscriber worker error-loops on the merged partition not existing. We
replay the MERGE locally on the subscriber, the worker continues
before we have a chance to run REFRESH PUBLICATION and discards the
UPDATE/INSERT.
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-11 21:36 Alexander Korotkov <aekorotkov@gmail.com>
parent: Zsolt Parragi <zsolt.parragi@percona.com>
0 siblings, 1 reply; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-11 21:36 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
Jian,
Zsolt,
Thank you both for your valuable catches. Attached is v3 addressing
the points raised.
On Fri, Aug 7, 2026 at 6:42 AM jian he <jian.universality@gmail.com> wrote:
> On Fri, Aug 7, 2026 at 6:59 AM Zsolt Parragi <zsolt.parragi@percona.com> wrote:
> > > Copying the value of generated column "as is" can produce data that differs from
> > > what the generated expression would compute if any merged partition's generation
> > > expression differs from the partitioned table's.
> >
> > I think this would be probably fine, as we can get the same effect by
> > replacing a function used by the expression, a preexisting condition
> > for many existing cases. But I do agree that requiring the same
> > expression is a better approach.
> >
> > Also, not directly related to this patch, but now that I looked into
> > this, I can still use tableoids for check constraints with a text
> > cast:
> >
> > CREATE TABLE t (i int) PARTITION BY RANGE (i);
> > CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
> > CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
> > ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
> > INSERT INTO t VALUES (0),(1);
> > ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; --
> > SUCCESS, but should ERROR instead?
>
> Interesting!
>
> Before we call MergePartitionsMoveRows, we did RestrictSearchPath(),
> which will set GUC search_path
> to "pg_catalog, pg_temp" temporally, and text_regclass will consider
> search_path when resolve object name.
>
> On the other hand, if we unconditionally validate all the partitioned
> table's inherited CHECK constraints, it may fail
> and the resulting message isn't helpful.
> The error message below shows what happens when evaluating all CHECK
> constraints during MERGE PARTITIONS.
>
> DROP TABLE IF EXISTS t;
> CREATE TABLE t (i int, b text default 't') PARTITION BY RANGE (i);
> CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
> CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
> ALTER TABLE t ADD CONSTRAINT cc CHECK (b::regclass::text in ('t',
> 'tp_0_1', 'tp_0_2', 'tp_1_2'));
> INSERT INTO t VALUES (0);
> INSERT INTO t VALUES (0, 'tp_0_1'), (1, 'tp_1_2'), (1, 'public.tp_1_2');
> ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
> ERROR: relation "t" does not exist
I see that virtual generated columns also can lead to the problems.
The revised 0003 rejects the dependency regardless of the generated
column kind, in a new checkPartitionSystemColumnRefs() called before
the new partition is created.
I confirm that CHECK constraints depending on a system column are also
problematic. The revised 0003 rejects CHECK constraints referencing a
system column as well, for the same reason as generated columns.
Since nothing needs re-verification anymore, the machinery that did it
is removed: buildPartitionCheckExprStates(),
checkPartitionRowConstraints(), the AlteredTableInfo.constraints
population, and the work queue entry and arguments that existed only
to carry them.
0002 also refuses to create the new partition in a schema whose FOR
TABLES IN SCHEMA publications differ from those of the source
partitions, since that would silently add the relocated rows to, or
remove them from, such a publication. The check triggers only when a
schema publication is actually involved, so a cross-schema MERGE/SPLIT
is still allowed otherwise; publications FOR ALL TABLES, or covering
the partitioned table itself, keep covering the new partitions and are
unaffected.
Agreed that the previous wording recommended something that runs into
your data-loss scenario. The paragraph now just states the facts: if
changes are published for the partitioned table itself, subscribers
are unaffected and may keep their own partition layout; otherwise the
new partition is not part of the subscription until it is refreshed,
and changes made in the meantime are not applied – so refreshing
without copying its data would silently lose them.
On the rewrite event trigger: MERGE/SPLIT doesn't fire table_rewrite,
and I don't think it should. table_rewrite reports a single table
that keeps its identity while getting a new relfilenode. MERGE turns
N partitions into one new relation and SPLIT one into N, dropping the
originals, so there is no single "table being rewritten" to report.
The commands are still visible to ddl_command_start/ddl_command_end as
an ALTER TABLE.
------
Regards,
Alexander Korotkov
Supabase
Attachments:
[application/x-patch] v3-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch (11.2K, ../../CAPpHfdvR-0=4ZFreeQpm2-JdyTw7Bge+vtYwKY1UKcvy+MW55w@mail.gmail.com/2-v3-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch)
download | inline diff:
From ffca690258ecdc00f606686f4bc6046bfbd4f49f Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:08:36 +0200
Subject: [PATCH v3 1/3] Don't logically decode MERGE/SPLIT PARTITION row
movement
ALTER TABLE ... MERGE/SPLIT PARTITION relocates rows between partitions of the
same partitioned table by re-inserting them into the freshly created
partition(s), using plain heap inserts. Logical decoding emitted those as
INSERTs into the new partition with no matching DELETEs for the source rows,
which corrupts logical replication subscribers.
Pass TABLE_INSERT_NO_LOGICAL to the movers so the relocation is not decoded,
just as CLUSTER and VACUUM FULL already do for their rewrites. MERGE/SPLIT
PARTITION is a schema change that is not itself replicated, and the moved rows
still exist on subscribers, so suppressing the inserts keeps them consistent.
Document the behavior in the MERGE PARTITIONS and SPLIT PARTITION
commands descriptions, and add a test_decoding regression test.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
contrib/test_decoding/Makefile | 3 +-
.../expected/partition_merge_split.out | 56 +++++++++++++++++++
contrib/test_decoding/meson.build | 1 +
.../sql/partition_merge_split.sql | 34 +++++++++++
doc/src/sgml/ref/alter_table.sgml | 30 ++++++++++
src/backend/commands/tablecmds.c | 20 +++++--
6 files changed, 139 insertions(+), 5 deletions(-)
create mode 100644 contrib/test_decoding/expected/partition_merge_split.out
create mode 100644 contrib/test_decoding/sql/partition_merge_split.sql
diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile
index 0111124399a..ab90cd7fec2 100644
--- a/contrib/test_decoding/Makefile
+++ b/contrib/test_decoding/Makefile
@@ -5,7 +5,8 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin"
REGRESS = ddl xact rewrite toast permissions decoding_in_xact \
decoding_into_rel binary prepared replorigin time messages \
- repack spill slot truncate stream stats twophase twophase_stream
+ repack spill slot truncate stream stats twophase twophase_stream \
+ partition_merge_split
ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \
oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \
twophase_snapshot slot_creation_error catalog_change_snapshot \
diff --git a/contrib/test_decoding/expected/partition_merge_split.out b/contrib/test_decoding/expected/partition_merge_split.out
new file mode 100644
index 00000000000..63ec5af98d0
--- /dev/null
+++ b/contrib/test_decoding/expected/partition_merge_split.out
@@ -0,0 +1,56 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+ ?column?
+----------
+ init
+(1 row)
+
+INSERT INTO part VALUES (1), (11);
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ count
+-------
+ 4
+(1 row)
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+--------------------------------------------
+ BEGIN
+ table public.part_1: INSERT: id[integer]:2
+ COMMIT
+(3 rows)
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+ ?column?
+----------
+ stop
+(1 row)
+
+DROP TABLE part;
diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build
index ac655853d26..a504bc00794 100644
--- a/contrib/test_decoding/meson.build
+++ b/contrib/test_decoding/meson.build
@@ -42,6 +42,7 @@ tests += {
'stats',
'twophase',
'twophase_stream',
+ 'partition_merge_split',
],
'regress_args': [
'--temp-config', files('logical.conf'),
diff --git a/contrib/test_decoding/sql/partition_merge_split.sql b/contrib/test_decoding/sql/partition_merge_split.sql
new file mode 100644
index 00000000000..efdd6019ebd
--- /dev/null
+++ b/contrib/test_decoding/sql/partition_merge_split.sql
@@ -0,0 +1,34 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+
+INSERT INTO part VALUES (1), (11);
+
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+DROP TABLE part;
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..b8246a7ee48 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Moving rows into the new partition does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... MERGE PARTITIONS</command> is a schema change and
+ is not itself replicated to logical replication subscribers. If changes
+ are published for the partitioned table itself (see
+ <literal>publish_via_partition_root</literal>), subscribers are unaffected
+ and may keep their own partition layout. Otherwise changes are published
+ for the individual partitions, and the new partition is not part of the
+ subscription until the subscription is refreshed; changes made to it in
+ the meantime are not applied, so refreshing without copying its data would
+ silently lose them.
+ </para>
+
<note>
<para>
Merging partitions acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
@@ -1386,6 +1401,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Moving rows into the new partitions does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... SPLIT PARTITION</command> is a schema change and
+ is not itself replicated to logical replication subscribers. If changes
+ are published for the partitioned table itself (see
+ <literal>publish_via_partition_root</literal>), subscribers are unaffected
+ and may keep their own partition layout. Otherwise changes are published
+ for the individual partitions, and the new partitions are not part of the
+ subscription until the subscription is refreshed; changes made to them in
+ the meantime are not applied, so refreshing without copying their data
+ would silently lose them.
+ </para>
+
<note>
<para>
Split partition acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 6d4c457b820..0eb85c1be17 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23366,8 +23366,16 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
AlteredTableInfo *tab;
ListCell *ltab;
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Also suppress logical
+ * decoding of these inserts: merging partitions physically relocates rows
+ * within the same partitioned table, much like CLUSTER or VACUUM FULL.
+ * The relocation is not a user-level INSERT, and MERGE PARTITIONS is DDL
+ * that logical replication does not replicate anyway; emitting INSERTs
+ * for the moved rows (with no matching DELETEs for the source rows) would
+ * corrupt logical subscribers.
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
@@ -24034,8 +24042,12 @@ static void
SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
List *partlist, List *newPartRels)
{
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Suppress logical decoding
+ * of these inserts as well; see the matching comment in
+ * MergePartitionsMoveRows().
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
CommandId mycid;
EState *estate;
ListCell *listptr,
--
2.55.0
[application/x-patch] v3-0002-Peserve-replica-identity-and-publications-in-MERG.patch (24.5K, ../../CAPpHfdvR-0=4ZFreeQpm2-JdyTw7Bge+vtYwKY1UKcvy+MW55w@mail.gmail.com/3-v3-0002-Peserve-replica-identity-and-publications-in-MERG.patch)
download | inline diff:
From 029ed90d8398828f8ab6df7d70cce9c84f453411 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:14:57 +0200
Subject: [PATCH v3 2/3] Peserve replica identity and publications in
MERGE/SPLIT PARTITION(s)
The new partition(s) created by ALTER TABLE ... MERGE/SPLIT PARTITION are
built from the partitioned-table template, so they would default to
REPLICA IDENTITY DEFAULT and silently drop out of any publication that the
source partitions were directly part of, changing replication behavior
without a warning.
Carry a uniform, simply-representable replica identity (DEFAULT, FULL or
NOTHING) from the source partitions to the new partition(s). Raise an error
if the sources disagree, or use an index-based identity that cannot be
reproduced automatically, and let the user set it explicitly. Also refuse
the operation when any source partition is a direct member of a publication:
the new partition would otherwise leave it, and faithfully reproducing
per-relation column lists and row filters is ambiguous (especially when
several sources are merged). Publications that cover the partitioned root
continue to include the new partition, so those are unaffected.
For the same reason, refuse to create the new partition in a schema whose
FOR TABLES IN SCHEMA publications differ from those of the source partitions:
such a move would silently add the relocated rows to, or remove them from,
such a publication. The check only triggers when a schema publication is
actually involved, so a cross-schema MERGE/SPLIT remains allowed otherwise;
publications FOR ALL TABLES, or covering the partitioned table itself, keep
covering the new partitions and are unaffected.
Document this behavior and add a test coverage.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 33 ++++
src/backend/commands/tablecmds.c | 148 ++++++++++++++++++
src/test/regress/expected/partition_merge.out | 62 ++++++++
src/test/regress/expected/partition_split.out | 60 +++++++
src/test/regress/sql/partition_merge.sql | 51 ++++++
src/test/regress/sql/partition_split.sql | 48 ++++++
6 files changed, 402 insertions(+)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index b8246a7ee48..04ab3d08bbd 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,24 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ The new partition takes its replica identity from the merged partitions
+ when they all use the same simple setting
+ (<literal>DEFAULT</literal>, <literal>FULL</literal> or
+ <literal>NOTHING</literal>). If they use different settings, or use
+ <literal>REPLICA IDENTITY USING INDEX</literal>, the error is issued
+ and the command is aborted. Give the partitions being merged a uniform,
+ non-index replica identity before merging, and set a different replica
+ identity on the resulting partition afterwards if desired. Likewise, if
+ any of the partitions being merged is directly part of a publication, the
+ command is aborted; publish the partitioned table itself instead of the
+ individual partitions, or remove the partition from the publication before
+ merging. For the same reason, the new partition cannot be created in a
+ schema that is not covered by the same publications defined
+ <literal>FOR TABLES IN SCHEMA</literal> as the schema of the partitions
+ being merged.
+ </para>
+
<para>
Moving rows into the new partition does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
@@ -1401,6 +1419,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ The new partitions take their replica identity from the split partition,
+ unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
+ case the error is issued and the command is aborted. Give the partition
+ being split a non-index replica identity before splitting, and set a
+ different replica identity on the new partitions afterwards if desired.
+ Likewise, if the partition being split is directly part of a publication,
+ the command is rejected; publish the partitioned table itself instead of
+ the individual partitions, or remove the partition from the publication
+ before splitting. For the same reason, the new partitions cannot be
+ created in a schema that is not covered by the same publications defined
+ <literal>FOR TABLES IN SCHEMA</literal> as the schema of the partition
+ being split.
+ </para>
+
<para>
Moving rows into the new partitions does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0eb85c1be17..46396117006 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -49,6 +49,7 @@
#include "catalog/pg_opclass.h"
#include "catalog/pg_policy.h"
#include "catalog/pg_proc.h"
+#include "catalog/pg_publication.h"
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_rewrite.h"
#include "catalog/pg_statistic_ext.h"
@@ -23353,6 +23354,137 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
return newRel;
}
+/*
+ * checkPartitionSchemaPublications: refuse MERGE/SPLIT when the new partition(s)
+ * would land in a schema whose FOR TABLES IN SCHEMA publications differ from
+ * those of the source partition(s).
+ *
+ * The new partitions are created under the name given in the command, which may
+ * name a different schema than the source partitions live in. A publication
+ * defined FOR TABLES IN SCHEMA covers exactly the tables of that schema, so such
+ * a move would silently add the relocated rows to, or remove them from, that
+ * publication. Publications FOR ALL TABLES, or covering the partitioned table
+ * itself, keep covering the new partitions and are therefore not a problem.
+ *
+ * 'sourceOids' lists the source partition OIDs, 'newPartRels' the new partition
+ * Relations.
+ */
+static void
+checkPartitionSchemaPublications(List *sourceOids, List *newPartRels)
+{
+ foreach_oid(srcOid, sourceOids)
+ {
+ Oid srcNsp = get_rel_namespace(srcOid);
+ List *srcPubs = NIL;
+ bool srcPubsFetched = false;
+
+ foreach_ptr(RelationData, newrel, newPartRels)
+ {
+ Oid newNsp = RelationGetNamespace(newrel);
+ List *newPubs;
+
+ /* Same schema: publication membership cannot change. */
+ if (newNsp == srcNsp)
+ continue;
+
+ if (!srcPubsFetched)
+ {
+ srcPubs = GetSchemaPublications(srcNsp);
+ srcPubsFetched = true;
+ }
+ newPubs = GetSchemaPublications(newNsp);
+
+ /* No schema publication involved, so nothing can change. */
+ if (srcPubs == NIL && newPubs == NIL)
+ continue;
+
+ if (list_length(srcPubs) != list_length(newPubs) ||
+ list_difference_oid(srcPubs, newPubs) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move partition \"%s\" to schema \"%s\" with different publications for tables in schema",
+ get_rel_name(srcOid),
+ get_namespace_name(newNsp)),
+ errdetail("Schema \"%s\" and schema \"%s\" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.",
+ get_namespace_name(srcNsp),
+ get_namespace_name(newNsp)),
+ errhint("Create the new partition in the same schema, or publish the partitioned table itself."));
+ }
+ }
+}
+
+/*
+ * transferPartitionReplicaIdentity: propagate the source partitions' replica
+ * identity to the new partition(s) created by MERGE/SPLIT, and refuse the
+ * operation for cases we cannot handle without silently changing replication
+ * behavior.
+ *
+ * The new partitions are built from the partitioned-table template and would
+ * otherwise default to REPLICA IDENTITY DEFAULT and drop out of any publication
+ * that the source partitions were directly part of. To avoid silent surprises:
+ *
+ * - A uniform, simply-representable replica identity (DEFAULT, FULL or
+ * NOTHING) is carried over to every new partition. If the sources disagree,
+ * or use an index-based identity (which cannot be reproduced on the new
+ * partition automatically), we raise an error and ask the user to set it.
+ *
+ * - If any source partition is a direct member of a publication, we refuse the
+ * operation: the new partition would silently leave the publication, and
+ * faithfully reproducing per-relation column lists and row filters is
+ * ambiguous (especially when several sources are merged). Publications that
+ * cover the partitioned root instead continue to include the new partition.
+ *
+ * 'sourceOids' lists the source partition OIDs (still present, not yet dropped);
+ * 'newPartRels' lists the new partition Relations (exclusively locked).
+ */
+static void
+transferPartitionReplicaIdentity(List *sourceOids, List *newPartRels)
+{
+ char ri_type = '\0';
+ bool ri_seen = false;
+
+ foreach_oid(srcOid, sourceOids)
+ {
+ Relation src = table_open(srcOid, NoLock);
+
+ if (GetRelationIncludedPublications(srcOid) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that is directly part of a publication",
+ RelationGetRelationName(src)),
+ errhint("Publish the partitioned table instead, or add the new partition to the publication after the operation."));
+
+ if (!ri_seen)
+ {
+ ri_type = src->rd_rel->relreplident;
+ ri_seen = true;
+ }
+ else if (ri_type != src->rd_rel->relreplident)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("partitions being merged have different replica identity settings"),
+ errhint("Set the replica identity of the new partition explicitly after the operation."));
+
+ table_close(src, NoLock);
+ }
+
+ /* Nothing to carry over, or the new partitions already match. */
+ if (!ri_seen || ri_type == REPLICA_IDENTITY_DEFAULT)
+ return;
+
+ if (ri_type == REPLICA_IDENTITY_INDEX)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot automatically transfer an index-based replica identity to the new partition"),
+ errhint("Set the replica identity of the new partition explicitly with ALTER TABLE ... REPLICA IDENTITY USING INDEX."));
+
+ /* Carry FULL / NOTHING over to each new partition. */
+ foreach_ptr(RelationData, newrel, newPartRels)
+ relation_mark_replica_identity(newrel, ri_type, InvalidOid, true);
+
+ CommandCounterIncrement();
+}
+
/*
* MergePartitionsMoveRows: scan partitions to be merged (mergingPartitions)
* of the partitioned table and move rows into the new partition
@@ -23903,6 +24035,14 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
Assert(OidIsValid(ownerId));
newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ /*
+ * Carry the source partitions' replica identity over to the new
+ * partition, and reject cases that would silently change replication
+ * behavior.
+ */
+ transferPartitionReplicaIdentity(mergingPartitions, list_make1(newPartRel));
+ checkPartitionSchemaPublications(mergingPartitions, list_make1(newPartRel));
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
@@ -24345,6 +24485,14 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
newPartRels = lappend(newPartRels, newPartRel);
}
+ /*
+ * Carry the split partition's replica identity over to the new
+ * partitions, and reject cases that would silently change replication
+ * behavior.
+ */
+ transferPartitionReplicaIdentity(list_make1_oid(splitRelOid), newPartRels);
+ checkPartitionSchemaPublications(list_make1_oid(splitRelOid), newPartRels);
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index ccda2b5843b..c00cd5b5599 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1167,6 +1167,68 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2'
+ AND relnamespace = 'partitions_merge_schema'::regnamespace;
+ relreplident
+--------------
+ f
+(1 row)
+
+DROP TABLE t;
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: partitions being merged have different replica identity settings
+HINT: Set the replica identity of the new partition explicitly after the operation.
+DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partition "tp_0_1" that is directly part of a publication
+HINT: Publish the partitioned table instead, or add the new partition to the publication after the operation.
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+-- Creating the new partition in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover it.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema merge is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO partitions_merge_schema2.tp_0_2;
+SELECT count(*) FROM t;
+ count
+-------
+ 2
+(1 row)
+
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLES IN SCHEMA partitions_merge_schema;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2)
+ INTO partitions_merge_schema2.tp_0_2; -- fails
+ERROR: cannot move partition "tp_0_1" to schema "partitions_merge_schema2" with different publications for tables in schema
+DETAIL: Schema "partitions_merge_schema" and schema "partitions_merge_schema2" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.
+HINT: Create the new partition in the same schema, or publish the partitioned table itself.
+-- Staying in the covered schema is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP PUBLICATION pub_merge;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 8e245563801..3f7d49b2204 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1751,6 +1751,66 @@ SELECT relname, reltablespace FROM pg_class
tp_lo | 0
(2 rows)
+DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2')
+ AND relnamespace = 'partition_split_schema'::regnamespace ORDER BY relname;
+ relname | relreplident
+---------+--------------
+ tp_0_1 | f
+ tp_1_2 | f
+(2 rows)
+
+DROP TABLE t;
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partition "tp_0_2" that is directly part of a publication
+HINT: Publish the partitioned table instead, or add the new partition to the publication after the operation.
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+-- Creating the new partitions in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover them.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema split is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT count(*) FROM t;
+ count
+-------
+ 2
+(1 row)
+
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLES IN SCHEMA partition_split_schema;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot move partition "tp_0_2" to schema "partition_split_schema2" with different publications for tables in schema
+DETAIL: Schema "partition_split_schema" and schema "partition_split_schema2" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.
+HINT: Create the new partition in the same schema, or publish the partitioned table itself.
+-- Staying in the covered schema is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP PUBLICATION pub_split;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 80dc365b0ce..63f1ccd0fba 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -839,6 +839,57 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2'
+ AND relnamespace = 'partitions_merge_schema'::regnamespace;
+DROP TABLE t;
+
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
+
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
+-- Creating the new partition in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover it.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema merge is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO partitions_merge_schema2.tp_0_2;
+SELECT count(*) FROM t;
+DROP TABLE t;
+
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLES IN SCHEMA partitions_merge_schema;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2)
+ INTO partitions_merge_schema2.tp_0_2; -- fails
+-- Staying in the covered schema is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
+
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index ffd15e7f969..c470c42be71 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1256,6 +1256,54 @@ SELECT relname, reltablespace FROM pg_class
WHERE relname IN ('tp_lo', 'tp_hi') ORDER BY relname;
DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2')
+ AND relnamespace = 'partition_split_schema'::regnamespace ORDER BY relname;
+DROP TABLE t;
+
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
+-- Creating the new partitions in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover them.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema split is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT count(*) FROM t;
+DROP TABLE t;
+
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLES IN SCHEMA partition_split_schema;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+-- Staying in the covered schema is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
RESET search_path;
--
--
2.55.0
[application/x-patch] v3-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch (52.0K, ../../CAPpHfdvR-0=4ZFreeQpm2-JdyTw7Bge+vtYwKY1UKcvy+MW55w@mail.gmail.com/4-v3-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch)
download | inline diff:
From b5060c24460e18fcad44b642f1c52555321dcd77 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:19:19 +0200
Subject: [PATCH v3 3/3] Don't recalculate generated columns during MERGE/SPLIT
PARTITION(S)
ALTER TABLE ... MERGE/SPLIT PARTITION unconditionally recomputed every stored
generated column of the moved rows using the partitioned table's generation
expression. When a leaf partition's generation expression -- or a function it
calls -- differed from the partitioned table's, this silently rewrote
already-stored values, and could even break constraints.
Relocating a row between partitions never changes a user column, so a stored
generated column defined over user columns yields the same value; move it as-is
instead of recomputing, as every other command preserves generated column
values. This alone removes the silent data changes and constraint violations
reported for such columns.
Moving values as-is is only correct when the source partition's generation
expression matches the partitioned table's. A partition can carry a different
expression (ATTACH PARTITION requires the generated-column kind to match but
does not compare the expressions), in which case the moved-as-is value would not
match the new partition's generation expression -- silently storing data
inconsistent with the schema, and possibly violating NOT NULL, CHECK, or
foreign-key constraints. Reject MERGE/SPLIT in that case, in the new
checkPartitionGenExprMatchesParent().
What does legitimately change on the move is tableoid, the only system column
allowed in such expressions, so the new checkPartitionSystemColumnRefs() rejects
every dependency on it:
- A stored generated column would have to be recomputed, but unlike a normal
insert the row-movement path does not re-verify NOT NULL, foreign-key, or
generated-column-dependent CHECK constraints, so a recomputed value could
silently violate them. A virtual generated column is not stored at all, so
its value would silently change as soon as the rows live in the new
partition, with the same consequences.
- A CHECK constraint would have to be re-verified against the new partition's
OID, and that cannot be done faithfully either: the row movement runs under
RestrictSearchPath(), so a search_path dependent expression such as
tableoid::regclass::text does not evaluate the way it would for a regular
INSERT, which makes the re-verification both unreliable and confusing.
As nothing is recomputed or re-verified anymore, the machinery that did so
during the row move is gone: createTableConstraints() no longer records
generated columns in AlteredTableInfo.newvals nor CHECK constraints in
AlteredTableInfo.constraints, and the two row-move helpers that evaluated them
are removed, along with the work queue entry and arguments that only existed to
carry them.
Document the behavior and add regression coverage for all three rejections.
Existing MERGE/SPLIT tests that relied on recomputation now assert the rejection
or use a generation expression matching the partitioned table, and a
function-change test shows a plain generated column's value preserved.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 30 ++
src/backend/commands/tablecmds.c | 395 +++++++++---------
src/test/regress/expected/partition_merge.out | 127 ++++--
src/test/regress/expected/partition_split.out | 74 +++-
src/test/regress/sql/partition_merge.sql | 88 +++-
src/test/regress/sql/partition_split.sql | 55 ++-
6 files changed, 485 insertions(+), 284 deletions(-)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 04ab3d08bbd..7f1c6133337 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Stored generated columns keep their existing values; the merge does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the merge is
+ rejected if a merged partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partition's
+ stored data inconsistent with its own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row-movement path cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partition takes its replica identity from the merged partitions
when they all use the same simple setting
@@ -1419,6 +1434,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Stored generated columns keep their existing values; the split does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the split is
+ rejected if the split partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partitions'
+ stored data inconsistent with their own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row-movement path cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partitions take their replica identity from the split partition,
unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 46396117006..7a76ebf3e3f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -22859,92 +22859,6 @@ GetAttributeStorage(Oid atttypid, const char *storagemode)
return cstorage;
}
-/*
- * buildExpressionExecutionStates: build the needed expression execution states
- * for new partition (newPartRel) checks and initialize expressions for
- * generated columns. All expressions should be created in "tab"
- * (AlteredTableInfo structure).
- */
-static void
-buildExpressionExecutionStates(AlteredTableInfo *tab, Relation newPartRel, EState *estate)
-{
- /*
- * Build the needed expression execution states. Here, we expect only NOT
- * NULL and CHECK constraint.
- */
- foreach_ptr(NewConstraint, con, tab->constraints)
- {
- switch (con->contype)
- {
- case CONSTR_CHECK:
-
- /*
- * We already expanded virtual expression in
- * createTableConstraints.
- */
- con->qualstate = ExecPrepareExpr((Expr *) con->qual, estate);
- break;
- case CONSTR_NOTNULL:
- /* Nothing to do here. */
- break;
- default:
- elog(ERROR, "unrecognized constraint type: %d",
- (int) con->contype);
- }
- }
-
- /* Expression already planned in createTableConstraints */
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- ex->exprstate = ExecInitExpr((Expr *) ex->expr, NULL);
-}
-
-/*
- * evaluateGeneratedExpressionsAndCheckConstraints: evaluate any generated
- * expressions for "tab" (AlteredTableInfo structure) whose inputs come from
- * the new tuple (insertslot) of the new partition (newPartRel).
- */
-static void
-evaluateGeneratedExpressionsAndCheckConstraints(AlteredTableInfo *tab,
- Relation newPartRel,
- TupleTableSlot *insertslot,
- ExprContext *econtext)
-{
- econtext->ecxt_scantuple = insertslot;
-
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- {
- if (!ex->is_generated)
- continue;
-
- insertslot->tts_values[ex->attnum - 1]
- = ExecEvalExpr(ex->exprstate,
- econtext,
- &insertslot->tts_isnull[ex->attnum - 1]);
- }
-
- foreach_ptr(NewConstraint, con, tab->constraints)
- {
- switch (con->contype)
- {
- case CONSTR_CHECK:
- if (!ExecCheck(con->qualstate, econtext))
- ereport(ERROR,
- errcode(ERRCODE_CHECK_VIOLATION),
- errmsg("check constraint \"%s\" of relation \"%s\" is violated by some row",
- con->name, RelationGetRelationName(newPartRel)),
- errtableconstraint(newPartRel, con->name));
- break;
- case CONSTR_NOTNULL:
- case CONSTR_FOREIGN:
- /* Nothing to do here */
- break;
- default:
- elog(ERROR, "unrecognized constraint type: %d",
- (int) con->contype);
- }
- }
-}
-
/*
* getAttributesList: build a list of columns (ColumnDef) based on parent_rel
*/
@@ -22995,15 +22909,171 @@ getAttributesList(Relation parent_rel)
return colList;
}
+/*
+ * expression_references_system_column: walker that returns true if the given
+ * expression references any system column (a Var with a negative attribute
+ * number, such as tableoid). Used to decide whether a stored generated column
+ * must be recomputed when a row is relocated between partitions.
+ */
+static bool
+expression_references_system_column(Node *node, void *context)
+{
+ if (node == NULL)
+ return false;
+ if (IsA(node, Var) && ((Var *) node)->varattno < 0)
+ return true;
+ return expression_tree_walker(node, expression_references_system_column,
+ context);
+}
+
+/*
+ * checkPartitionSystemColumnRefs: reject MERGE/SPLIT PARTITION when the
+ * partitioned table has a generated column or a CHECK constraint whose
+ * expression references a system column.
+ *
+ * Only tableoid may appear in such expressions, and it is precisely the value
+ * that changes when a row is relocated into the new partition. Neither
+ * dependency can be honored during the row movement:
+ *
+ * - A stored generated column would have to be recomputed, but the row-movement
+ * path does not re-verify NOT NULL, foreign-key, or generated-column-dependent
+ * CHECK constraints the way a normal insert does, so a recomputed value could
+ * silently violate them. A virtual generated column is not stored at all, so
+ * its value silently changes as soon as the rows live in the new partition.
+ *
+ * - A CHECK constraint would have to be re-verified against the new partition's
+ * OID. We cannot do that faithfully either: the row movement runs under
+ * RestrictSearchPath(), so a search_path-dependent expression such as
+ * tableoid::regclass::text does not evaluate the way it would for a regular
+ * INSERT, which would make the re-verification both unreliable and confusing.
+ *
+ * So reject these cases and let the user handle such columns and constraints
+ * explicitly. In the future we may implement recomputation together with a
+ * full re-validation of the affected constraints.
+ */
+static void
+checkPartitionSystemColumnRefs(Relation parent_rel)
+{
+ TupleDesc tupleDesc = RelationGetDescr(parent_rel);
+ TupleConstr *constr = tupleDesc->constr;
+
+ if (constr == NULL)
+ return;
+
+ /* Generated columns, both stored and virtual. */
+ if (constr->has_generated_stored || constr->has_generated_virtual)
+ {
+ for (AttrNumber attno = 1; attno <= tupleDesc->natts; attno++)
+ {
+ Form_pg_attribute attr = TupleDescAttr(tupleDesc, attno - 1);
+
+ if (attr->attisdropped || attr->attgenerated == '\0')
+ continue;
+
+ if (expression_references_system_column(build_generation_expression(parent_rel, attno),
+ NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a generated column depends on a system column"),
+ errdetail("Column \"%s\" of relation \"%s\" is generated from an expression that references a system column such as tableoid.",
+ NameStr(attr->attname),
+ RelationGetRelationName(parent_rel)));
+ }
+ }
+
+ /* CHECK constraints. */
+ for (int ccnum = 0; ccnum < constr->num_check; ccnum++)
+ {
+ if (expression_references_system_column(stringToNode(constr->check[ccnum].ccbin),
+ NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a check constraint depends on a system column"),
+ errdetail("Constraint \"%s\" of relation \"%s\" references a system column such as tableoid.",
+ constr->check[ccnum].ccname,
+ RelationGetRelationName(parent_rel)));
+ }
+}
+
+/*
+ * checkPartitionGenExprMatchesParent: reject MERGE/SPLIT PARTITION when a
+ * source partition has a generated column whose generation expression differs
+ * from the partitioned table's.
+ *
+ * MERGE/SPLIT PARTITION relocates rows into the new partition and copies stored
+ * generated columns as-is rather than recomputing them (see
+ * createTableConstraints()). Since the new partition is created from the
+ * partitioned table as a template, moving values as-is is only correct when the
+ * source partition's generation expression matches the partitioned table's.
+ * Otherwise the moved value would not match the new partition's generation
+ * expression, silently storing data inconsistent with the schema and possibly
+ * violating NOT NULL, CHECK, or foreign-key constraints.
+ *
+ * A partition can end up with a generation expression different from the
+ * partitioned table's via ATTACH PARTITION, which requires the generated-column
+ * kind to match but does not compare the expressions themselves (see
+ * MergeAttributesIntoExisting()).
+ */
+static void
+checkPartitionGenExprMatchesParent(Relation parent_rel, Relation partRel)
+{
+ TupleDesc parentDesc = RelationGetDescr(parent_rel);
+ TupleConstr *constr = parentDesc->constr;
+ AttrMap *attmap = NULL;
+
+ /* Nothing to compare if the partitioned table has no generated columns. */
+ if (constr == NULL ||
+ !(constr->has_generated_stored || constr->has_generated_virtual))
+ return;
+
+ for (AttrNumber parent_attno = 1; parent_attno <= parentDesc->natts;
+ parent_attno++)
+ {
+ Form_pg_attribute pattr = TupleDescAttr(parentDesc, parent_attno - 1);
+ AttrNumber child_attno;
+ Node *parentExpr;
+ Node *childExpr;
+ bool found_whole_row;
+
+ if (pattr->attisdropped || pattr->attgenerated == '\0')
+ continue;
+
+ /*
+ * Column names match between a partitioned table and its partitions,
+ * and so does the generated-column kind; only the expression can
+ * differ (all enforced/allowed by MergeAttributesIntoExisting()).
+ */
+ child_attno = get_attnum(RelationGetRelid(partRel), NameStr(pattr->attname));
+ Assert(child_attno != InvalidAttrNumber);
+
+ parentExpr = build_generation_expression(parent_rel, parent_attno);
+ childExpr = build_generation_expression(partRel, child_attno);
+
+ /* Rewrite the partition's expression into the parent's numbering. */
+ if (attmap == NULL)
+ attmap = build_attrmap_by_name(parentDesc,
+ RelationGetDescr(partRel), false);
+ childExpr = map_variable_attnos(childExpr, 1, 0, attmap,
+ InvalidOid, &found_whole_row);
+
+ if (found_whole_row || !equal(parentExpr, childExpr))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a partition's generation expression differs from the partitioned table"),
+ errdetail("Generated column \"%s\" of partition \"%s\" has a generation expression different from table \"%s\".",
+ NameStr(pattr->attname),
+ RelationGetRelationName(partRel),
+ RelationGetRelationName(parent_rel)));
+ }
+}
+
/*
* createTableConstraints:
- * create check constraints, default values, and generated values for newRel
- * based on parent_rel. tab is pending-work queue for newRel, we may need it in
- * MergePartitionsMoveRows.
+ * create check constraints and column defaults (including generation
+ * expressions) for newRel based on parent_rel.
*/
static void
-createTableConstraints(List **wqueue, AlteredTableInfo *tab,
- Relation parent_rel, Relation newRel)
+createTableConstraints(Relation parent_rel, Relation newRel)
{
TupleDesc tupleDesc;
TupleConstr *constr;
@@ -23045,7 +23115,6 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
bool found_whole_row;
AttrNumber num;
Node *def;
- NewColumnValue *newval;
if (attribute->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
this_default = build_generation_expression(parent_rel, attribute->attnum);
@@ -23067,19 +23136,18 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
StoreAttrDefault(newRel, num, def, false);
/*
- * Stored generated column expressions in parent_rel might
- * reference the tableoid. newRel, parent_rel tableoid clear is
- * not the same. If so, these stored generated columns require
- * recomputation for newRel within MergePartitionsMoveRows.
+ * Relocating a row between partitions never changes a user
+ * column, so a stored generated column defined over user columns
+ * keeps the same value; we move it as-is rather than recomputing
+ * it, which is what every other command does. (A source
+ * partition whose generation expression differs from the
+ * partitioned table's has already been rejected by
+ * checkPartitionGenExprMatchesParent(), and an expression
+ * depending on a system column by
+ * checkPartitionSystemColumnRefs(); moving as-is here also avoids
+ * silently rewriting stored data when a function the expression
+ * calls has since been redefined.)
*/
- if (attribute->attgenerated == ATTRIBUTE_GENERATED_STORED)
- {
- newval = palloc0_object(NewColumnValue);
- newval->attnum = num;
- newval->expr = expression_planner((Expr *) def);
- newval->is_generated = (attribute->attgenerated != '\0');
- tab->newvals = lappend(tab->newvals, newval);
- }
}
}
@@ -23138,40 +23206,13 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
CommandCounterIncrement();
/*
- * parent_rel check constraint expression may reference tableoid, so later
- * in MergePartitionsMoveRows, we need to evaluate the check constraint
- * again for the newRel. We can check whether the check constraint
- * contains a tableoid reference via pull_varattnos.
+ * The relocated rows satisfy the new partition's CHECK constraints
+ * without any re-verification here: the constraints are copied from the
+ * partitioned table, which the source partitions already inherited, and
+ * the row movement changes no column value. Constraints depending on a
+ * system column, the one thing that does change, were rejected by
+ * checkPartitionSystemColumnRefs().
*/
- foreach_ptr(CookedConstraint, ccon, cookedConstraints)
- {
- if (!ccon->skip_validation)
- {
- Node *qual;
- Bitmapset *attnums = NULL;
-
- Assert(ccon->contype == CONSTR_CHECK);
- qual = expand_generated_columns_in_expr(ccon->expr, newRel, 1);
- pull_varattnos(qual, 1, &attnums);
-
- /*
- * Add a check only if it contains a tableoid
- * (TableOidAttributeNumber).
- */
- if (bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber,
- attnums))
- {
- NewConstraint *newcon;
-
- newcon = palloc0_object(NewConstraint);
- newcon->name = ccon->name;
- newcon->contype = CONSTR_CHECK;
- newcon->qual = qual;
-
- tab->constraints = lappend(tab->constraints, newcon);
- }
- }
- }
/* Don't need the cookedConstraints anymore. */
list_free_deep(cookedConstraints);
@@ -23209,7 +23250,7 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
* Returns the created relation (locked in AccessExclusiveLock mode).
*/
static Relation
-createPartitionTable(List **wqueue, RangeVar *newPartName,
+createPartitionTable(RangeVar *newPartName,
Relation parent_rel, Oid ownerId)
{
Relation newRel;
@@ -23220,7 +23261,6 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
List *colList = NIL;
Oid relamId;
Oid namespaceId;
- AlteredTableInfo *new_partrel_tab;
Form_pg_class parent_relform = parent_rel->rd_rel;
/* If the existing rel is temp, it must belong to this session. */
@@ -23339,11 +23379,8 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
*/
newRel = table_open(newRelId, NoLock);
- /* Find or create a work queue entry for the newly created table. */
- new_partrel_tab = ATGetQueueEntry(wqueue, newRel);
-
/* Create constraints, default values, and generated values. */
- createTableConstraints(wqueue, new_partrel_tab, parent_rel, newRel);
+ createTableConstraints(parent_rel, newRel);
/*
* Need to call CommandCounterIncrement, so a fresh relcache entry has
@@ -23511,14 +23548,8 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
- /* Find the work queue entry for the new partition table: newPartRel. */
- tab = ATGetQueueEntry(wqueue, newPartRel);
-
- /* Generate the constraint and default execution states. */
estate = CreateExecutorState();
- buildExpressionExecutionStates(tab, newPartRel, estate);
-
mycid = GetCurrentCommandId(true);
/* Prepare a BulkInsertState for table_tuple_insert. */
@@ -23594,22 +23625,6 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
ExecStoreVirtualTuple(insertslot);
}
- /*
- * Constraints and GENERATED expressions might reference the
- * tableoid column, so fill tts_tableOid with the desired value.
- * (We must do this each time, because it gets overwritten with
- * newrel's OID during storing.)
- */
- insertslot->tts_tableOid = RelationGetRelid(newPartRel);
-
- /*
- * Now, evaluate any generated expressions whose inputs come from
- * the new tuple. We assume these columns won't reference each
- * other, so that there's no ordering dependency.
- */
- evaluateGeneratedExpressionsAndCheckConstraints(tab, newPartRel,
- insertslot, econtext);
-
/* Write the tuple out to the new relation. */
table_tuple_insert(newPartRel, insertslot, mycid,
ti_options, bistate);
@@ -23906,6 +23921,13 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
int save_sec_context;
int save_nestlevel;
+ /*
+ * The rows are relocated as-is, but a generated column or CHECK
+ * constraint depending on a system column would change meaning in the new
+ * partition.
+ */
+ checkPartitionSystemColumnRefs(rel);
+
/*
* Check ownership of merged partitions - partitions with different owners
* cannot be merged. Also, collect the OIDs of these partitions during the
@@ -23934,6 +23956,14 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
else
ownerId = mergingPartition->rd_rel->relowner;
+ /*
+ * The new partition inherits the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data.
+ */
+ checkPartitionGenExprMatchesParent(rel, mergingPartition);
+
/* Store the next merging partition into the list. */
mergingPartitions = lappend_oid(mergingPartitions,
RelationGetRelid(mergingPartition));
@@ -24033,7 +24063,7 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
* model.
*/
Assert(OidIsValid(ownerId));
- newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ newPartRel = createPartitionTable(cmd->name, rel, ownerId);
/*
* Carry the source partitions' replica identity over to the new
@@ -24212,11 +24242,6 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
pc = createSplitPartitionContext((Relation) lfirst(listptr2));
- /* Find the work queue entry for the new partition table: newPartRel. */
- pc->tab = ATGetQueueEntry(wqueue, pc->partRel);
-
- buildExpressionExecutionStates(pc->tab, pc->partRel, estate);
-
if (sps->bound->is_default)
{
/*
@@ -24334,22 +24359,6 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
ExecStoreVirtualTuple(insertslot);
}
- /*
- * Constraints and GENERATED expressions might reference the tableoid
- * column, so fill tts_tableOid with the desired value. (We must do
- * this each time, because it gets overwritten with newrel's OID
- * during storing.)
- */
- insertslot->tts_tableOid = RelationGetRelid(pc->partRel);
-
- /*
- * Now, evaluate any generated expressions whose inputs come from the
- * new tuple. We assume these columns won't reference each other, so
- * that there's no ordering dependency.
- */
- evaluateGeneratedExpressionsAndCheckConstraints(pc->tab, pc->partRel,
- insertslot, econtext);
-
/* Write the tuple out to the new relation. */
table_tuple_insert(pc->partRel, insertslot, mycid,
ti_options, pc->bistate);
@@ -24405,6 +24414,16 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
splitRelOid = RelationGetRelid(splitRel);
+ /*
+ * The new partitions inherit the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a split partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data. Likewise reject expressions depending on a system
+ * column, whose value changes in the new partitions.
+ */
+ checkPartitionSystemColumnRefs(rel);
+ checkPartitionGenExprMatchesParent(rel, splitRel);
+
/* Check descriptions of new partitions. */
foreach_node(SinglePartitionSpec, sps, cmd->partlist)
{
@@ -24480,7 +24499,7 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
{
Relation newPartRel;
- newPartRel = createPartitionTable(wqueue, sps->name, rel,
+ newPartRel = createPartitionTable(sps->name, rel,
splitRel->rd_rel->relowner);
newPartRels = lappend(newPartRels, newPartRel);
}
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index c00cd5b5599..6a9b2f97b2c 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -887,14 +887,14 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -926,7 +926,7 @@ CREATE TRIGGER tp_1_2_before_insert_row_trigger BEFORE INSERT ON tp_1_2 FOR EACH
i | integer | | not null | | plain | | | tp_0_1.i
t | text | | | 'default_tp_0_1'::text | main | | |
b | bigint | | not null | | plain | | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | | |
Partition of: t FOR VALUES FROM (0) TO (1)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 1))
Check constraints:
@@ -1030,37 +1030,50 @@ ERROR: insert or update on table "t_fk" violates foreign key constraint "t_fk_i
DETAIL: Key (i)=(2) is not present in table "t".
DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so MERGE PARTITIONS is rejected. This holds for a stored column,
+-- whose value cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partition (here that
+-- would also break the NOT NULL constraint if the new partition happened to
+-- get the OID mentioned in the expression). A generated column over user
+-- columns only is fine: its value is preserved, as exercised above.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "g" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partition's OID, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a check constraint depends on a system column
+DETAIL: Constraint "cc" of relation "t" references a system column such as tableoid.
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i * 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i * 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i * 2), i int);
ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
ALTER TABLE t ADD CHECK (g > 0);
@@ -1070,24 +1083,17 @@ ALTER TABLE t MERGE PARTITIONS (tp_1, tp_2) INTO tp_12;
INSERT INTO t VALUES (16);
-- ERROR
INSERT INTO t VALUES (0);
-ERROR: new row for relation "tp_12" violates check constraint "t_i_check"
+ERROR: new row for relation "tp_12" violates check constraint "t_g_check"
DETAIL: Failing row contains (0, virtual).
-- Should be 3 rows: (5), (15), (16):
-SELECT i FROM t ORDER BY i;
- i
-----
- 5
- 15
- 16
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+----
+ 5 | 10
+ 15 | 30
+ 16 | 32
(3 rows)
--- Should be 1 because for the same tableoid (15 + tableoid) = (5 + tableoid) + 10:
-SELECT count(*) FROM t WHERE i = 15 AND g IN (SELECT g + 10 FROM t WHERE i = 5);
- count
--------
- 1
-(1 row)
-
DROP TABLE t;
-- A merged partition needs its own TOAST table; otherwise an out-of-line
-- varlena value carried over from one of the merging partitions has
@@ -1167,6 +1173,45 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+-----
+ 3 | 6
+ 5 | 500
+ 12 | 24
+(3 rows)
+
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_10" has a generation expression different from table "t".
DROP TABLE t;
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 3f7d49b2204..e5f07dacb25 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1547,7 +1547,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -1576,7 +1576,7 @@ CREATE TRIGGER tp_x_before_insert_row_trigger BEFORE INSERT ON tp_x FOR EACH ROW
i | integer | | not null | | plain | | tp_x.i
t | text | | | 'default_tp_x'::text | main | |
b | bigint | | not null | | plain | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | |
Partition of: t FOR VALUES FROM (0) TO (2)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 2))
Check constraints:
@@ -1627,32 +1627,60 @@ SELECT tableoid::regclass, * FROM t ORDER BY tableoid::regclass::text COLLATE "C
DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so SPLIT PARTITION is rejected -- for a stored column, whose value
+-- cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partitions.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "g" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partitions' OIDs, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_1');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a check constraint depends on a system column
+DETAIL: Constraint "cc" of relation "t" references a system column such as tableoid.
+DROP TABLE t;
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_20" has a generation expression different from table "t".
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
-- that out-of-line varlena attributes coming from the source partition
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 63f1ccd0fba..09c05d8d08f 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -649,7 +649,7 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
@@ -657,7 +657,7 @@ CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
@@ -736,33 +736,49 @@ DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so MERGE PARTITIONS is rejected. This holds for a stored column,
+-- whose value cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partition (here that
+-- would also break the NOT NULL constraint if the new partition happened to
+-- get the OID mentioned in the expression). A generated column over user
+-- columns only is fine: its value is preserved, as exercised above.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partition's OID, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i * 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i * 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i * 2), i int);
ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
ALTER TABLE t ADD CHECK (g > 0);
@@ -775,9 +791,7 @@ INSERT INTO t VALUES (16);
-- ERROR
INSERT INTO t VALUES (0);
-- Should be 3 rows: (5), (15), (16):
-SELECT i FROM t ORDER BY i;
--- Should be 1 because for the same tableoid (15 + tableoid) = (5 + tableoid) + 10:
-SELECT count(*) FROM t WHERE i = 15 AND g IN (SELECT g + 10 FROM t WHERE i = 5);
+SELECT i, g FROM t ORDER BY i;
DROP TABLE t;
@@ -839,6 +853,40 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+
+
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+DROP TABLE t;
+
+
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index c470c42be71..db383c1ff30 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1122,7 +1122,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
@@ -1162,26 +1162,57 @@ DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so SPLIT PARTITION is rejected -- for a stored column, whose value
+-- cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
+
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partitions.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partitions' OIDs, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_1');
+INSERT INTO t VALUES (0), (1);
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
--
2.55.0
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-12 20:38 Zsolt Parragi <zsolt.parragi@percona.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
0 siblings, 0 replies; 40+ messages in thread
From: Zsolt Parragi @ 2026-08-12 20:38 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, jian he <jian.universality@gmail.com>
Hello
v3 looks good to me, I only have two nitpick comments:
1. MergePartitionsMoveRows now has a stale comment ("We also verify
check constraints againsy these rows")
2. I am unsure of the usefulness of some of the error hints, for example:
+ errhint("Set the replica identity of the new partition
explicitly after the operation."));
The hint is true, the user has to set up replica identity after the
merge if he needs it, but the operation can't be executed as-is, so
the user first have to solve the current situation. I also don't have
a better idea how to explain this without an overly long error hint,
so maybe it's good as is.
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-12 20:48 Melanie Plageman <melanieplageman@gmail.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
1 sibling, 3 replies; 40+ messages in thread
From: Melanie Plageman @ 2026-08-12 20:48 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers <pgsql-hackers@postgresql.org>; jian he <jian.universality@gmail.com>
On Mon, Aug 3, 2026 at 6:03 PM Alexander Korotkov <aekorotkov@gmail.com> wrote:
>
> On Thu, Jul 23, 2026 at 1:59 PM Zsolt Parragi <zsolt.parragi@percona.com> wrote:
> >
>
> > 5. In (2) I mentioned replication-related inheritance questions, but
> > it is much more generic than that, many partition specific details get
> > lost silently:
> > * indexes
> > * constraints
> > * different DEFAULTs
> > * foreign keys
> > * triggers
> > * reloptions
> > * custom tablespace
> > * table AM
> > * per column settings
> > * security labels
> > * ACLs
> > * RLS policies
> >
> > Shouldn't most of these copied into split partitions, and handled
> > properly in merges (erroring out in non trivial cases?)
> >
> > Silently dropping them doesn't seem like a good behavior, as it can
> > cause many different issues:
> > * dropping foreign keys / checks can cause data integrity issues
> > * dropping partition specific sequences can cause later inserts to
> > fail or silently fall back to nulls/different values
> > * probably many other scenarios I didn't think of
>
> This was intended to keep patches simple enough for pg 19. That's
> documented that we copy properties from parent, but don't copy from
> previous partitions(s) [1][2]. We may implement other options in
> further releases.
I'm worried that despite the documentation, users might find this
surprising -- and by the time they realize it happened, it might be
too late. For example, in the following SQL, before the SPLIT
partition, Carol can't see the secret row when querying parent or
leaf, but after the split, she can query the leaf partition directly
(holding the same data as what she previously queried) and she can see
the secret row
CREATE ROLE carol LOGIN;
GRANT pg_read_all_data TO carol;
CREATE TABLE events3 (id int, secret boolean, data text) PARTITION BY
RANGE (id);
CREATE TABLE ev3_0_100 PARTITION OF events3 FOR VALUES FROM (0) TO (100);
INSERT INTO events3 VALUES (1,false,'public-row'), (2,true,'TOP-SECRET-row');
ALTER TABLE events3 ENABLE ROW LEVEL SECURITY;
CREATE POLICY hide_secret_parent ON events3 FOR SELECT USING (secret = false);
ALTER TABLE ev3_0_100 ENABLE ROW LEVEL SECURITY;
CREATE POLICY hide_secret_leaf ON ev3_0_100 FOR SELECT USING (secret = false);
SET ROLE carol;
SELECT * FROM events3 ORDER BY id;
SELECT * FROM ev3_0_100 ORDER BY id;
RESET ROLE;
ALTER TABLE events3 SPLIT PARTITION ev3_0_100 INTO
(PARTITION ev3_0_50 FOR VALUES FROM (0) TO (50),
PARTITION ev3_50_100 FOR VALUES FROM (50) TO (100));
SET ROLE carol;
SELECT * FROM events3 ORDER BY id;
SELECT * FROM ev3_0_50 ORDER BY id;
RESET ROLE;
The user needs to add RLS to the new leaf partitions if they want the
same level of security, but I'm not sure that's intuitive.
Also, for merging partitions, if you merge two partitions that have
the same RLS, after merging, the new merged partition doesn't have
that RLS policy -- that seems confusing too
GRANT pg_read_all_data TO carol;
CREATE TABLE events (id int, secret boolean, data text) PARTITION BY RANGE (id);
CREATE TABLE ev_a PARTITION OF events FOR VALUES FROM (0) TO (50);
CREATE TABLE ev_b PARTITION OF events FOR VALUES FROM (50) TO (100);
INSERT INTO events VALUES (10, false, 'A-public'), (20, true, 'A-SECRET'),
(60, false, 'B-public'), (70, true, 'B-SECRET');
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
CREATE POLICY hide_secret ON events FOR SELECT USING (secret = false);
ALTER TABLE ev_a ENABLE ROW LEVEL SECURITY;
CREATE POLICY hide_secret ON ev_a FOR SELECT USING (secret = false);
ALTER TABLE ev_b ENABLE ROW LEVEL SECURITY;
CREATE POLICY hide_secret ON ev_b FOR SELECT USING (secret = false);
SET ROLE carol;
SELECT * FROM events ORDER BY id;
SELECT * FROM ev_a ORDER BY id;
SELECT * FROM ev_b ORDER BY id;
RESET ROLE;
ALTER TABLE events MERGE PARTITIONS (ev_a, ev_b) INTO ev_merged;
SET ROLE carol;
SELECT * FROM events ORDER BY id;
SELECT * FROM ev_merged ORDER BY id;
RESET ROLE;
- Melanie
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-12 20:56 Zsolt Parragi <zsolt.parragi@percona.com>
parent: Melanie Plageman <melanieplageman@gmail.com>
2 siblings, 0 replies; 40+ messages in thread
From: Zsolt Parragi @ 2026-08-12 20:56 UTC (permalink / raw)
To: Melanie Plageman <melanieplageman@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, Alexander Korotkov <aekorotkov@gmail.com>
> I'm worried that despite the documentation, users might find this
> surprising -- and by the time they realize it happened, it might be
> too late.
This was one of my reasons for mentioning it. Printing out at least a
WARNING for them would make them more visible (but it still has the
problem that the mistake already happened - what if the user didn't
dump the settings before splitting?), or it could be even an ERROR by
default that would require an extra clause to override. But either of
those requires at least the code to detect these issues.
My other worry is that it could be also confusing if we have silently
different behavior in 19 and 20. Let's say all of these will be
implemented in PG20 and later. And then a dba has to deal with some
merge/split on a PG19 server, and doesn't realize that some settings
are now missing, because it works differently in 20/21/... So maybe
even with support in later versions, it would require something like
INCLUDING ALL?
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-14 14:07 Daniel Gustafsson <daniel@yesql.se>
parent: Melanie Plageman <melanieplageman@gmail.com>
2 siblings, 1 reply; 40+ messages in thread
From: Daniel Gustafsson @ 2026-08-14 14:07 UTC (permalink / raw)
To: Melanie Plageman <melanieplageman@gmail.com>; +Cc: Alexander Korotkov <aekorotkov@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>; jian he <jian.universality@gmail.com>
> On 12 Aug 2026, at 22:48, Melanie Plageman <melanieplageman@gmail.com> wrote:
> On Mon, Aug 3, 2026 at 6:03 PM Alexander Korotkov <aekorotkov@gmail.com> wrote:
>>
>> On Thu, Jul 23, 2026 at 1:59 PM Zsolt Parragi <zsolt.parragi@percona.com> wrote:
>>> 5. In (2) I mentioned replication-related inheritance questions, but
>>> it is much more generic than that, many partition specific details get
>>> lost silently:
>>> * indexes
>>> * constraints
>>> * different DEFAULTs
>>> * foreign keys
>>> * triggers
>>> * reloptions
>>> * custom tablespace
>>> * table AM
>>> * per column settings
>>> * security labels
>>> * ACLs
>>> * RLS policies
>>>
>>> Shouldn't most of these copied into split partitions, and handled
>>> properly in merges (erroring out in non trivial cases?)
>>>
>>> Silently dropping them doesn't seem like a good behavior, as it can
>>> cause many different issues:
>>> * dropping foreign keys / checks can cause data integrity issues
>>> * dropping partition specific sequences can cause later inserts to
>>> fail or silently fall back to nulls/different values
>>> * probably many other scenarios I didn't think of
>>
>> This was intended to keep patches simple enough for pg 19. That's
>> documented that we copy properties from parent, but don't copy from
>> previous partitions(s) [1][2]. We may implement other options in
>> further releases.
>
> I'm worried that despite the documentation, users might find this
> surprising -- and by the time they realize it happened, it might be
> too late.
Apart from the obviously dangerous ones like RLS and ACL, silently dropping the
table AM may induce side-effects which are hard for us to even reason about
since they are external to the core code. AFAICT we don't document that a
table can move out of the TAM, if even briefly.
I don't disagree with limiting scope to make a patch reviewable in a first
version, but I think this should do so by rejecting any cases where options are
silently dropped instead. What if the code checks both partitions for being
equal to the parent, and only allow a MERGE when all parameters can be kept due
to them being equal?
It's true that the behaviour is documented, but I don't think it's entirely
easy to grasp as the list of things being dropped is incomplete with an "etc":
"But extended statistics, security policies, etc, won't be copied from
the partitioned table."
> ...
>
> The user needs to add RLS to the new leaf partitions if they want the
> same level of security, but I'm not sure that's intuitive.
It's not, and it quite easily will leave the data without the intended
protection during a window.
> Also, for merging partitions, if you merge two partitions that have
> the same RLS, after merging, the new merged partition doesn't have
> that RLS policy -- that seems confusing too
I would rank this as even more unintuitive than the previous case, as a user I
would expect the new partition to have the shared policy.
Could we make this safe by restricting to the cases where partitions match the
parent and we can make them not drop characteristics? If we want to expand
which differences can be handled in a safe manner in 20 then we can revisit,
rather than being very lax now and try to restrict later.
--
Daniel Gustafsson
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-14 14:36 Nathan Bossart <nathandbossart@gmail.com>
parent: Melanie Plageman <melanieplageman@gmail.com>
2 siblings, 0 replies; 40+ messages in thread
From: Nathan Bossart @ 2026-08-14 14:36 UTC (permalink / raw)
To: Melanie Plageman <melanieplageman@gmail.com>; +Cc: Alexander Korotkov <aekorotkov@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers <pgsql-hackers@postgresql.org>; jian he <jian.universality@gmail.com>
On Wed, Aug 12, 2026 at 04:48:36PM -0400, Melanie Plageman wrote:
>> > 5. In (2) I mentioned replication-related inheritance questions, but
>> > it is much more generic than that, many partition specific details get
>> > lost silently:
>> > * indexes
>> > * constraints
>> > * different DEFAULTs
>> > * foreign keys
>> > * triggers
>> > * reloptions
>> > * custom tablespace
>> > * table AM
>> > * per column settings
>> > * security labels
>> > * ACLs
>> > * RLS policies
>> >
>> > Shouldn't most of these copied into split partitions, and handled
>> > properly in merges (erroring out in non trivial cases?)
>> >
>> > Silently dropping them doesn't seem like a good behavior, as it can
>> > cause many different issues:
>> > * dropping foreign keys / checks can cause data integrity issues
>> > * dropping partition specific sequences can cause later inserts to
>> > fail or silently fall back to nulls/different values
>> > * probably many other scenarios I didn't think of
>>
>> This was intended to keep patches simple enough for pg 19. That's
>> documented that we copy properties from parent, but don't copy from
>> previous partitions(s) [1][2]. We may implement other options in
>> further releases.
>
> I'm worried that despite the documentation, users might find this
> surprising -- and by the time they realize it happened, it might be
> too late.
>
> [...]
>
> The user needs to add RLS to the new leaf partitions if they want the
> same level of security, but I'm not sure that's intuitive.
>
> Also, for merging partitions, if you merge two partitions that have
> the same RLS, after merging, the new merged partition doesn't have
> that RLS policy -- that seems confusing too
+1. I'm looking at the current form of the documentation:
It is the user's responsibility to setup ACL on the new partition.
Does this mean that the merged partition is accessible to PUBLIC at first?
Or that it's not accessible to anyone? I think this could be explained in
greater detail.
Constraints, column defaults, column generation expressions, identity
columns, indexes, and triggers are copied from the partitioned table to
the new partition. But extended statistics, security policies, etc,
won't be copied from the partitioned table.
I think the "etc" is doing a lot of heavy lifting here. Does this mean
that only the things in the first list are handled, and everything else is
not?
When partitions are merged, any objects depending on this partition,
such as constraints, triggers, extended statistics, etc, will be
dropped.
Which partition does "this partition" refer to?
Eventually, we will drop all the merged partitions (using RESTRICT
mode) too; therefore, if any objects are still dependent on them, ALTER
TABLE MERGE PARTITION would fail.
I think this would be clearer if we had specific terms for the partitions
involved. For example, we could call the partitions that are getting
merged "source partitions", and the result of the merge the "merged
partition" or "destination partition". To me, the above sentence sounds
like we are dropping the destination/merged partition, but I'm pretty sure
that's not what it means.
Much of the above applies to SPLIT PARTITION as well. I'm sympathetic to
the idea of keeping things restricted at first to make the project more
feasible, but this is a pretty lengthy set of limitations that IMHO
deserves more prominence in the documentation (maybe even a warning). I
think it'd also be a good idea to call out that these limitations by go
away in future releases.
I haven't looked at the patches, but the size of the patches, and the fact
there there are apparently still rather large problems, does make me
somewhat concerned about this feature's readiness for v19.
--
nathan
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-14 14:51 Melanie Plageman <melanieplageman@gmail.com>
parent: Daniel Gustafsson <daniel@yesql.se>
0 siblings, 1 reply; 40+ messages in thread
From: Melanie Plageman @ 2026-08-14 14:51 UTC (permalink / raw)
To: Daniel Gustafsson <daniel@yesql.se>; +Cc: Alexander Korotkov <aekorotkov@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>; jian he <jian.universality@gmail.com>
On Fri, Aug 14, 2026 at 10:08 AM Daniel Gustafsson <daniel@yesql.se> wrote:
>
> > On 12 Aug 2026, at 22:48, Melanie Plageman <melanieplageman@gmail.com> wrote:
>
> > The user needs to add RLS to the new leaf partitions if they want the
> > same level of security, but I'm not sure that's intuitive.
>
> It's not, and it quite easily will leave the data without the intended
> protection during a window.
>
> > Also, for merging partitions, if you merge two partitions that have
> > the same RLS, after merging, the new merged partition doesn't have
> > that RLS policy -- that seems confusing too
>
> I would rank this as even more unintuitive than the previous case, as a user I
> would expect the new partition to have the shared policy.
>
> Could we make this safe by restricting to the cases where partitions match the
> parent and we can make them not drop characteristics? If we want to expand
> which differences can be handled in a safe manner in 20 then we can revisit,
> rather than being very lax now and try to restrict later.
Yes, I don't think it makes sense to silently drop the properties in
19 and then start automatically propagating them in 20. That seems
like it will be really confusing for users that have scripts to, for
example, recreate ACLs for the merged or split partition(s) when using
19.
- Melanie
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-14 15:49 Alexander Korotkov <aekorotkov@gmail.com>
parent: Melanie Plageman <melanieplageman@gmail.com>
0 siblings, 1 reply; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-14 15:49 UTC (permalink / raw)
To: Melanie Plageman <melanieplageman@gmail.com>; +Cc: Daniel Gustafsson <daniel@yesql.se>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>; jian he <jian.universality@gmail.com>
On Fri, Aug 14, 2026 at 5:51 PM Melanie Plageman
<melanieplageman@gmail.com> wrote:
>
> On Fri, Aug 14, 2026 at 10:08 AM Daniel Gustafsson <daniel@yesql.se> wrote:
> >
> > > On 12 Aug 2026, at 22:48, Melanie Plageman <melanieplageman@gmail.com> wrote:
> >
> > > The user needs to add RLS to the new leaf partitions if they want the
> > > same level of security, but I'm not sure that's intuitive.
> >
> > It's not, and it quite easily will leave the data without the intended
> > protection during a window.
> >
> > > Also, for merging partitions, if you merge two partitions that have
> > > the same RLS, after merging, the new merged partition doesn't have
> > > that RLS policy -- that seems confusing too
> >
> > I would rank this as even more unintuitive than the previous case, as a user I
> > would expect the new partition to have the shared policy.
> >
> > Could we make this safe by restricting to the cases where partitions match the
> > parent and we can make them not drop characteristics? If we want to expand
> > which differences can be handled in a safe manner in 20 then we can revisit,
> > rather than being very lax now and try to restrict later.
>
> Yes, I don't think it makes sense to silently drop the properties in
> 19 and then start automatically propagating them in 20. That seems
> like it will be really confusing for users that have scripts to, for
> example, recreate ACLs for the merged or split partition(s) when using
> 19.
I agree that this kind of changing behavior is not acceptable. My
proposal is to reject partitions with row-level security/policies for
19. Then we could add automatic copy of row-level security/policies
for 20. If changing one behavior to another incompatible behavior is
not acceptable, but changing from ERRCODE_FEATURE_NOT_SUPPORTED to new
behavior seems acceptable (new releases support more features). Or
alternatively we could add copying of row-level security/policies as
an option in SQL statement for 20.
SPLIT/MERGE partition(s) seemed like not so complex feature, but many
aspects like this arise. It would be nice if we could come with some
restricted version for 19, and expand it for 20 and later releases
(rather than re-trying large patchset for 20).
Attached 0004 implements check that source partition doesn't have
ow-level security/policies.
0003 also have integrated edits proposed by Zsolt [1].
Links.
1. https://www.postgresql.org/message-id/CAN4CZFMNhEF85h7h1su30h9E4cExGKpSSViZ2EqggNqAX%2BXtng%40mail.g...
------
Regards,
Alexander Korotkov
Supabase
Attachments:
[application/octet-stream] v4-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch (11.2K, ../../CAPpHfdvgCQhFMqtqQ6aVbT03kqp13HAC4qHZ64vgc4OnT7TRgA@mail.gmail.com/2-v4-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch)
download | inline diff:
From ffca690258ecdc00f606686f4bc6046bfbd4f49f Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:08:36 +0200
Subject: [PATCH v4 1/4] Don't logically decode MERGE/SPLIT PARTITION row
movement
ALTER TABLE ... MERGE/SPLIT PARTITION relocates rows between partitions of the
same partitioned table by re-inserting them into the freshly created
partition(s), using plain heap inserts. Logical decoding emitted those as
INSERTs into the new partition with no matching DELETEs for the source rows,
which corrupts logical replication subscribers.
Pass TABLE_INSERT_NO_LOGICAL to the movers so the relocation is not decoded,
just as CLUSTER and VACUUM FULL already do for their rewrites. MERGE/SPLIT
PARTITION is a schema change that is not itself replicated, and the moved rows
still exist on subscribers, so suppressing the inserts keeps them consistent.
Document the behavior in the MERGE PARTITIONS and SPLIT PARTITION
commands descriptions, and add a test_decoding regression test.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
contrib/test_decoding/Makefile | 3 +-
.../expected/partition_merge_split.out | 56 +++++++++++++++++++
contrib/test_decoding/meson.build | 1 +
.../sql/partition_merge_split.sql | 34 +++++++++++
doc/src/sgml/ref/alter_table.sgml | 30 ++++++++++
src/backend/commands/tablecmds.c | 20 +++++--
6 files changed, 139 insertions(+), 5 deletions(-)
create mode 100644 contrib/test_decoding/expected/partition_merge_split.out
create mode 100644 contrib/test_decoding/sql/partition_merge_split.sql
diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile
index 0111124399a..ab90cd7fec2 100644
--- a/contrib/test_decoding/Makefile
+++ b/contrib/test_decoding/Makefile
@@ -5,7 +5,8 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin"
REGRESS = ddl xact rewrite toast permissions decoding_in_xact \
decoding_into_rel binary prepared replorigin time messages \
- repack spill slot truncate stream stats twophase twophase_stream
+ repack spill slot truncate stream stats twophase twophase_stream \
+ partition_merge_split
ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \
oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \
twophase_snapshot slot_creation_error catalog_change_snapshot \
diff --git a/contrib/test_decoding/expected/partition_merge_split.out b/contrib/test_decoding/expected/partition_merge_split.out
new file mode 100644
index 00000000000..63ec5af98d0
--- /dev/null
+++ b/contrib/test_decoding/expected/partition_merge_split.out
@@ -0,0 +1,56 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+ ?column?
+----------
+ init
+(1 row)
+
+INSERT INTO part VALUES (1), (11);
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ count
+-------
+ 4
+(1 row)
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+--------------------------------------------
+ BEGIN
+ table public.part_1: INSERT: id[integer]:2
+ COMMIT
+(3 rows)
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+ ?column?
+----------
+ stop
+(1 row)
+
+DROP TABLE part;
diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build
index ac655853d26..a504bc00794 100644
--- a/contrib/test_decoding/meson.build
+++ b/contrib/test_decoding/meson.build
@@ -42,6 +42,7 @@ tests += {
'stats',
'twophase',
'twophase_stream',
+ 'partition_merge_split',
],
'regress_args': [
'--temp-config', files('logical.conf'),
diff --git a/contrib/test_decoding/sql/partition_merge_split.sql b/contrib/test_decoding/sql/partition_merge_split.sql
new file mode 100644
index 00000000000..efdd6019ebd
--- /dev/null
+++ b/contrib/test_decoding/sql/partition_merge_split.sql
@@ -0,0 +1,34 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+
+INSERT INTO part VALUES (1), (11);
+
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+DROP TABLE part;
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..b8246a7ee48 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Moving rows into the new partition does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... MERGE PARTITIONS</command> is a schema change and
+ is not itself replicated to logical replication subscribers. If changes
+ are published for the partitioned table itself (see
+ <literal>publish_via_partition_root</literal>), subscribers are unaffected
+ and may keep their own partition layout. Otherwise changes are published
+ for the individual partitions, and the new partition is not part of the
+ subscription until the subscription is refreshed; changes made to it in
+ the meantime are not applied, so refreshing without copying its data would
+ silently lose them.
+ </para>
+
<note>
<para>
Merging partitions acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
@@ -1386,6 +1401,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Moving rows into the new partitions does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... SPLIT PARTITION</command> is a schema change and
+ is not itself replicated to logical replication subscribers. If changes
+ are published for the partitioned table itself (see
+ <literal>publish_via_partition_root</literal>), subscribers are unaffected
+ and may keep their own partition layout. Otherwise changes are published
+ for the individual partitions, and the new partitions are not part of the
+ subscription until the subscription is refreshed; changes made to them in
+ the meantime are not applied, so refreshing without copying their data
+ would silently lose them.
+ </para>
+
<note>
<para>
Split partition acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 6d4c457b820..0eb85c1be17 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23366,8 +23366,16 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
AlteredTableInfo *tab;
ListCell *ltab;
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Also suppress logical
+ * decoding of these inserts: merging partitions physically relocates rows
+ * within the same partitioned table, much like CLUSTER or VACUUM FULL.
+ * The relocation is not a user-level INSERT, and MERGE PARTITIONS is DDL
+ * that logical replication does not replicate anyway; emitting INSERTs
+ * for the moved rows (with no matching DELETEs for the source rows) would
+ * corrupt logical subscribers.
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
@@ -24034,8 +24042,12 @@ static void
SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
List *partlist, List *newPartRels)
{
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Suppress logical decoding
+ * of these inserts as well; see the matching comment in
+ * MergePartitionsMoveRows().
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
CommandId mycid;
EState *estate;
ListCell *listptr,
--
2.55.0
[application/octet-stream] v4-0002-Peserve-replica-identity-and-publications-in-MERG.patch (24.8K, ../../CAPpHfdvgCQhFMqtqQ6aVbT03kqp13HAC4qHZ64vgc4OnT7TRgA@mail.gmail.com/3-v4-0002-Peserve-replica-identity-and-publications-in-MERG.patch)
download | inline diff:
From 2462acd1e12e194487ed7782b9094d291015db17 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:14:57 +0200
Subject: [PATCH v4 2/4] Peserve replica identity and publications in
MERGE/SPLIT PARTITION(s)
The new partition(s) created by ALTER TABLE ... MERGE/SPLIT PARTITION are
built from the partitioned-table template, so they would default to
REPLICA IDENTITY DEFAULT and silently drop out of any publication that the
source partitions were directly part of, changing replication behavior
without a warning.
Carry a uniform, simply-representable replica identity (DEFAULT, FULL or
NOTHING) from the source partitions to the new partition(s). Raise an error
if the sources disagree, or use an index-based identity that cannot be
reproduced automatically, and let the user set it explicitly. Also refuse
the operation when any source partition is a direct member of a publication:
the new partition would otherwise leave it, and faithfully reproducing
per-relation column lists and row filters is ambiguous (especially when
several sources are merged). Publications that cover the partitioned root
continue to include the new partition, so those are unaffected.
For the same reason, refuse to create the new partition in a schema whose
FOR TABLES IN SCHEMA publications differ from those of the source partitions:
such a move would silently add the relocated rows to, or remove them from,
such a publication. The check only triggers when a schema publication is
actually involved, so a cross-schema MERGE/SPLIT remains allowed otherwise;
publications FOR ALL TABLES, or covering the partitioned table itself, keep
covering the new partitions and are unaffected.
Also make the error hints name an action that lets the command succeed, rather
than one to perform after an operation that did not happen.
Document this behavior and add a test coverage.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 35 +++++
src/backend/commands/tablecmds.c | 148 ++++++++++++++++++
src/test/regress/expected/partition_merge.out | 62 ++++++++
src/test/regress/expected/partition_split.out | 60 +++++++
src/test/regress/sql/partition_merge.sql | 50 ++++++
src/test/regress/sql/partition_split.sql | 48 ++++++
6 files changed, 403 insertions(+)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index b8246a7ee48..73e1be7dec8 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ The new partition takes its replica identity from the merged partitions
+ when they all use the same simple setting
+ (<literal>DEFAULT</literal>, <literal>FULL</literal> or
+ <literal>NOTHING</literal>). If they use different settings, or use
+ <literal>REPLICA IDENTITY USING INDEX</literal>, the error is issued
+ and the command is aborted. Give the partitions being merged a uniform,
+ non-index replica identity before merging, and set a different replica
+ identity on the resulting partition afterwards if desired. Likewise, if
+ any of the partitions being merged is directly part of a publication, the
+ command is aborted; publish the partitioned table itself instead of the
+ individual partitions, or remove the partition from the publication before
+ merging. For the same reason, the new partition cannot be created in a
+ schema that is not covered by the same publications defined
+ <literal>FOR TABLES IN SCHEMA</literal> as the schema of the partitions
+ being merged.
+ </para>
+
+
<para>
Moving rows into the new partition does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
@@ -1401,6 +1420,22 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ The new partitions take their replica identity from the split partition,
+ unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
+ case the error is issued and the command is aborted. Give the partition
+ being split a non-index replica identity before splitting, and set a
+ different replica identity on the new partitions afterwards if desired.
+ Likewise, if the partition being split is directly part of a publication,
+ the command is rejected; publish the partitioned table itself instead of
+ the individual partitions, or remove the partition from the publication
+ before splitting. For the same reason, the new partitions cannot be
+ created in a schema that is not covered by the same publications defined
+ <literal>FOR TABLES IN SCHEMA</literal> as the schema of the partition
+ being split.
+ </para>
+
+
<para>
Moving rows into the new partitions does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0eb85c1be17..1749ad68f68 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -49,6 +49,7 @@
#include "catalog/pg_opclass.h"
#include "catalog/pg_policy.h"
#include "catalog/pg_proc.h"
+#include "catalog/pg_publication.h"
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_rewrite.h"
#include "catalog/pg_statistic_ext.h"
@@ -23353,6 +23354,137 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
return newRel;
}
+/*
+ * checkPartitionSchemaPublications: refuse MERGE/SPLIT when the new partition(s)
+ * would land in a schema whose FOR TABLES IN SCHEMA publications differ from
+ * those of the source partition(s).
+ *
+ * The new partitions are created under the name given in the command, which may
+ * name a different schema than the source partitions live in. A publication
+ * defined FOR TABLES IN SCHEMA covers exactly the tables of that schema, so such
+ * a move would silently add the relocated rows to, or remove them from, that
+ * publication. Publications FOR ALL TABLES, or covering the partitioned table
+ * itself, keep covering the new partitions and are therefore not a problem.
+ *
+ * 'sourceOids' lists the source partition OIDs, 'newPartRels' the new partition
+ * Relations.
+ */
+static void
+checkPartitionSchemaPublications(List *sourceOids, List *newPartRels)
+{
+ foreach_oid(srcOid, sourceOids)
+ {
+ Oid srcNsp = get_rel_namespace(srcOid);
+ List *srcPubs = NIL;
+ bool srcPubsFetched = false;
+
+ foreach_ptr(RelationData, newrel, newPartRels)
+ {
+ Oid newNsp = RelationGetNamespace(newrel);
+ List *newPubs;
+
+ /* Same schema: publication membership cannot change. */
+ if (newNsp == srcNsp)
+ continue;
+
+ if (!srcPubsFetched)
+ {
+ srcPubs = GetSchemaPublications(srcNsp);
+ srcPubsFetched = true;
+ }
+ newPubs = GetSchemaPublications(newNsp);
+
+ /* No schema publication involved, so nothing can change. */
+ if (srcPubs == NIL && newPubs == NIL)
+ continue;
+
+ if (list_length(srcPubs) != list_length(newPubs) ||
+ list_difference_oid(srcPubs, newPubs) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move partition \"%s\" to schema \"%s\" with different publications for tables in schema",
+ get_rel_name(srcOid),
+ get_namespace_name(newNsp)),
+ errdetail("Schema \"%s\" and schema \"%s\" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.",
+ get_namespace_name(srcNsp),
+ get_namespace_name(newNsp)),
+ errhint("Create the new partition in the same schema, or publish the partitioned table itself."));
+ }
+ }
+}
+
+/*
+ * transferPartitionReplicaIdentity: propagate the source partitions' replica
+ * identity to the new partition(s) created by MERGE/SPLIT, and refuse the
+ * operation for cases we cannot handle without silently changing replication
+ * behavior.
+ *
+ * The new partitions are built from the partitioned-table template and would
+ * otherwise default to REPLICA IDENTITY DEFAULT and drop out of any publication
+ * that the source partitions were directly part of. To avoid silent surprises:
+ *
+ * - A uniform, simply-representable replica identity (DEFAULT, FULL or
+ * NOTHING) is carried over to every new partition. If the sources disagree,
+ * or use an index-based identity (which cannot be reproduced on the new
+ * partition automatically), we raise an error and ask the user to set it.
+ *
+ * - If any source partition is a direct member of a publication, we refuse the
+ * operation: the new partition would silently leave the publication, and
+ * faithfully reproducing per-relation column lists and row filters is
+ * ambiguous (especially when several sources are merged). Publications that
+ * cover the partitioned root instead continue to include the new partition.
+ *
+ * 'sourceOids' lists the source partition OIDs (still present, not yet dropped);
+ * 'newPartRels' lists the new partition Relations (exclusively locked).
+ */
+static void
+transferPartitionReplicaIdentity(List *sourceOids, List *newPartRels)
+{
+ char ri_type = '\0';
+ bool ri_seen = false;
+
+ foreach_oid(srcOid, sourceOids)
+ {
+ Relation src = table_open(srcOid, NoLock);
+
+ if (GetRelationIncludedPublications(srcOid) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that is directly part of a publication",
+ RelationGetRelationName(src)),
+ errhint("Publish the partitioned table instead, or remove the partition from the publication before the operation and add the new partition to it afterwards."));
+
+ if (!ri_seen)
+ {
+ ri_type = src->rd_rel->relreplident;
+ ri_seen = true;
+ }
+ else if (ri_type != src->rd_rel->relreplident)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("partitions being merged have different replica identity settings"),
+ errhint("Give all partitions being merged the same replica identity before merging."));
+
+ table_close(src, NoLock);
+ }
+
+ /* Nothing to carry over, or the new partitions already match. */
+ if (!ri_seen || ri_type == REPLICA_IDENTITY_DEFAULT)
+ return;
+
+ if (ri_type == REPLICA_IDENTITY_INDEX)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot automatically transfer an index-based replica identity to the new partition"),
+ errhint("Change the replica identity to a non-index one before the operation, then set it on the new partition with ALTER TABLE ... REPLICA IDENTITY USING INDEX."));
+
+ /* Carry FULL / NOTHING over to each new partition. */
+ foreach_ptr(RelationData, newrel, newPartRels)
+ relation_mark_replica_identity(newrel, ri_type, InvalidOid, true);
+
+ CommandCounterIncrement();
+}
+
/*
* MergePartitionsMoveRows: scan partitions to be merged (mergingPartitions)
* of the partitioned table and move rows into the new partition
@@ -23903,6 +24035,14 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
Assert(OidIsValid(ownerId));
newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ /*
+ * Carry the source partitions' replica identity over to the new
+ * partition, and reject cases that would silently change replication
+ * behavior.
+ */
+ transferPartitionReplicaIdentity(mergingPartitions, list_make1(newPartRel));
+ checkPartitionSchemaPublications(mergingPartitions, list_make1(newPartRel));
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
@@ -24345,6 +24485,14 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
newPartRels = lappend(newPartRels, newPartRel);
}
+ /*
+ * Carry the split partition's replica identity over to the new
+ * partitions, and reject cases that would silently change replication
+ * behavior.
+ */
+ transferPartitionReplicaIdentity(list_make1_oid(splitRelOid), newPartRels);
+ checkPartitionSchemaPublications(list_make1_oid(splitRelOid), newPartRels);
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index ccda2b5843b..0c19e5fa93f 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1167,6 +1167,68 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2'
+ AND relnamespace = 'partitions_merge_schema'::regnamespace;
+ relreplident
+--------------
+ f
+(1 row)
+
+DROP TABLE t;
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: partitions being merged have different replica identity settings
+HINT: Give all partitions being merged the same replica identity before merging.
+DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partition "tp_0_1" that is directly part of a publication
+HINT: Publish the partitioned table instead, or remove the partition from the publication before the operation and add the new partition to it afterwards.
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+-- Creating the new partition in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover it.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema merge is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO partitions_merge_schema2.tp_0_2;
+SELECT count(*) FROM t;
+ count
+-------
+ 2
+(1 row)
+
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLES IN SCHEMA partitions_merge_schema;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2)
+ INTO partitions_merge_schema2.tp_0_2; -- fails
+ERROR: cannot move partition "tp_0_1" to schema "partitions_merge_schema2" with different publications for tables in schema
+DETAIL: Schema "partitions_merge_schema" and schema "partitions_merge_schema2" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.
+HINT: Create the new partition in the same schema, or publish the partitioned table itself.
+-- Staying in the covered schema is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP PUBLICATION pub_merge;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 8e245563801..c086f7d2d05 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1751,6 +1751,66 @@ SELECT relname, reltablespace FROM pg_class
tp_lo | 0
(2 rows)
+DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2')
+ AND relnamespace = 'partition_split_schema'::regnamespace ORDER BY relname;
+ relname | relreplident
+---------+--------------
+ tp_0_1 | f
+ tp_1_2 | f
+(2 rows)
+
+DROP TABLE t;
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partition "tp_0_2" that is directly part of a publication
+HINT: Publish the partitioned table instead, or remove the partition from the publication before the operation and add the new partition to it afterwards.
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+-- Creating the new partitions in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover them.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema split is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT count(*) FROM t;
+ count
+-------
+ 2
+(1 row)
+
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLES IN SCHEMA partition_split_schema;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot move partition "tp_0_2" to schema "partition_split_schema2" with different publications for tables in schema
+DETAIL: Schema "partition_split_schema" and schema "partition_split_schema2" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.
+HINT: Create the new partition in the same schema, or publish the partitioned table itself.
+-- Staying in the covered schema is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP PUBLICATION pub_split;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 80dc365b0ce..9c41b252ad3 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -839,6 +839,56 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2'
+ AND relnamespace = 'partitions_merge_schema'::regnamespace;
+DROP TABLE t;
+
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
+
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
+-- Creating the new partition in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover it.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema merge is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO partitions_merge_schema2.tp_0_2;
+SELECT count(*) FROM t;
+DROP TABLE t;
+
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLES IN SCHEMA partitions_merge_schema;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2)
+ INTO partitions_merge_schema2.tp_0_2; -- fails
+-- Staying in the covered schema is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index ffd15e7f969..c470c42be71 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1256,6 +1256,54 @@ SELECT relname, reltablespace FROM pg_class
WHERE relname IN ('tp_lo', 'tp_hi') ORDER BY relname;
DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2')
+ AND relnamespace = 'partition_split_schema'::regnamespace ORDER BY relname;
+DROP TABLE t;
+
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
+-- Creating the new partitions in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover them.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema split is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT count(*) FROM t;
+DROP TABLE t;
+
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLES IN SCHEMA partition_split_schema;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+-- Staying in the covered schema is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
RESET search_path;
--
--
2.55.0
[application/octet-stream] v4-0004-Reject-MERGE-SPLIT-of-partitions-with-row-level-s.patch (15.3K, ../../CAPpHfdvgCQhFMqtqQ6aVbT03kqp13HAC4qHZ64vgc4OnT7TRgA@mail.gmail.com/4-v4-0004-Reject-MERGE-SPLIT-of-partitions-with-row-level-s.patch)
download | inline diff:
From 86a03e01c48e6f9059dc31fd8b7f98ea7b3ed711 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Fri, 14 Aug 2026 13:57:26 +0300
Subject: [PATCH v4 4/4] Reject MERGE/SPLIT of partitions with row-level
security
The new partitions created by ALTER TABLE ... MERGE/SPLIT PARTITION are built
from the partitioned table as a template, and row-level security is not part of
that template: policies are not inherited by partitions, and CREATE TABLE ...
LIKE does not copy them either. A source partition that has row security
enabled -- or that has row security enabled with no policy at all, which denies
access outright -- was therefore replaced by a partition that restricts nothing,
silently exposing rows that were hidden until then to anyone able to query the
partition directly.
Unlike the loss of a privilege grant, which only takes access away and is
noticed immediately, this fails in the unsafe direction and is easy to miss long
after the fact. So refuse the operation instead, and let the user re-establish
row security on the new partitions explicitly. Policies defined while row
security is disabled hide nothing today, but they are user-written definitions
that would likewise disappear without a trace, so those are refused as well.
Only the source partitions are examined. Row security on the partitioned table
keeps applying to queries against it, and a partition that never had row
security of its own loses nothing, so neither case is restricted.
Document this behavior and add regression coverage, including the cases that
must keep working: row security on the partitioned table alone, and partitions
without row security of their own.
Reported-by: Melanie Plageman <melanieplageman@gmail.com>
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 18 ++++++
src/backend/commands/tablecmds.c | 61 +++++++++++++++++++
src/test/regress/expected/partition_merge.out | 25 ++++++++
src/test/regress/expected/partition_split.out | 27 ++++++++
src/test/regress/sql/partition_merge.sql | 21 +++++++
src/test/regress/sql/partition_split.sql | 22 +++++++
6 files changed, 174 insertions(+)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0acaa23083b..009230eefae 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1314,6 +1314,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
being merged.
</para>
+ <para>
+ Row-level security is likewise not carried over: the new partition is
+ built from the partitioned table, which does not pass its policies down to
+ its partitions. A partition that has row-level security enabled, or that
+ has policies of its own, is therefore rejected, since the new partition
+ would otherwise expose rows that the merged partitions currently hide.
+ Disable row-level security and drop the policies before merging, and
+ re-establish them on the new partition afterwards.
+ </para>
<para>
Moving rows into the new partition does not emit logical replication
@@ -1465,6 +1474,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
being split.
</para>
+ <para>
+ Row-level security is likewise not carried over: the new partitions are
+ built from the partitioned table, which does not pass its policies down to
+ its partitions. A partition that has row-level security enabled, or that
+ has policies of its own, is therefore rejected, since the new partitions
+ would otherwise expose rows that the partition being split currently
+ hides. Disable row-level security and drop the policies before splitting,
+ and re-establish them on the new partitions afterwards.
+ </para>
<para>
Moving rows into the new partitions does not emit logical replication
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 923794dbd5e..da6cea4967d 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -66,6 +66,7 @@
#include "commands/repack.h"
#include "commands/sequence.h"
#include "commands/tablecmds.h"
+#include "commands/policy.h"
#include "commands/tablespace.h"
#include "commands/trigger.h"
#include "commands/typecmds.h"
@@ -23391,6 +23392,54 @@ createPartitionTable(RangeVar *newPartName,
return newRel;
}
+/*
+ * checkPartitionRowSecurity: refuse MERGE/SPLIT when a source partition has
+ * row-level security of its own.
+ *
+ * The new partitions are built from the partitioned-table template, and row
+ * security is not part of that template: it is neither inherited from the
+ * partitioned table nor copied from the source partitions (CREATE TABLE ...
+ * LIKE does not copy policies either). A partition that restricts, or with
+ * row security enabled and no policy outright denies, direct access to its rows
+ * would therefore be replaced by one that does not, silently exposing rows that
+ * were hidden until now. Unlike the loss of a privilege grant, which merely
+ * takes access away, this fails in the unsafe direction and is easy to miss, so
+ * refuse the operation instead and let the user re-establish row security on
+ * the new partitions explicitly. Policies defined while row security is
+ * disabled hide nothing today, but they are user-written definitions that would
+ * likewise disappear without a trace, so those are refused as well.
+ *
+ * Only the source partitions are examined. Row security on the partitioned
+ * table keeps applying to queries against it, and a partition that never had
+ * row security of its own does not lose any.
+ */
+static void
+checkPartitionRowSecurity(List *sourceOids)
+{
+ foreach_oid(srcOid, sourceOids)
+ {
+ Relation src = table_open(srcOid, NoLock);
+
+ if (src->rd_rel->relrowsecurity || src->rd_rel->relforcerowsecurity)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that has row-level security enabled",
+ RelationGetRelationName(src)),
+ errdetail("Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides."),
+ errhint("Disable row-level security on the partition before the operation, and re-establish it on the new partition afterwards."));
+
+ if (relation_has_policies(src))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that has row-level security policies",
+ RelationGetRelationName(src)),
+ errdetail("The policies are not carried over to the new partition and would be silently lost."),
+ errhint("Drop the policies from the partition before the operation, and define them on the new partition afterwards."));
+
+ table_close(src, NoLock);
+ }
+}
+
/*
* checkPartitionSchemaPublications: refuse MERGE/SPLIT when the new partition(s)
* would land in a schema whose FOR TABLES IN SCHEMA publications differ from
@@ -23971,6 +24020,12 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
table_close(mergingPartition, NoLock);
}
+ /*
+ * Row security of the merged partitions is not carried over to the new
+ * partition; reject rather than silently dropping it.
+ */
+ checkPartitionRowSecurity(mergingPartitions);
+
/* Look up the existing relation by the new partition name. */
RangeVarGetAndCheckCreationNamespace(cmd->name, NoLock, &existingRelid);
@@ -24414,6 +24469,12 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
splitRelOid = RelationGetRelid(splitRel);
+ /*
+ * Row security of the split partition is not carried over to the new
+ * partitions; reject rather than silently dropping it.
+ */
+ checkPartitionRowSecurity(list_make1_oid(splitRelOid));
+
/*
* The new partitions inherit the partitioned table's generation
* expressions, but rows are moved as-is; reject a split partition whose
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 10844fd9f9b..7e1aac3b44d 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1275,6 +1275,31 @@ HINT: Create the new partition in the same schema, or publish the partitioned t
ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
DROP PUBLICATION pub_merge;
DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition with row-level security of its own: it
+-- is not carried over, so the new partition would expose rows the merged
+-- partitions hide. Policies defined while row security is disabled are
+-- rejected too, as they would be lost without a trace.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_1 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partition "tp_0_1" that has row-level security enabled
+DETAIL: Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides.
+HINT: Disable row-level security on the partition before the operation, and re-establish it on the new partition afterwards.
+ALTER TABLE tp_0_1 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- still fails
+ERROR: cannot merge or split partition "tp_0_1" that has row-level security policies
+DETAIL: The policies are not carried over to the new partition and would be silently lost.
+HINT: Drop the policies from the partition before the operation, and define them on the new partition afterwards.
+DROP POLICY hide_secret ON tp_0_1;
+-- Row security on the partitioned table alone is fine: the partitions have
+-- none of their own, so nothing is lost.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP TABLE t;
RESET search_path;
--
DROP SCHEMA partitions_merge_schema;
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 9757a48464f..bd1132c4242 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1840,6 +1840,33 @@ ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
DROP PUBLICATION pub_split;
DROP TABLE t;
+-- SPLIT PARTITION rejects a partition with row-level security of its own, for
+-- the same reason as MERGE.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partition "tp_0_2" that has row-level security enabled
+DETAIL: Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides.
+HINT: Disable row-level security on the partition before the operation, and re-establish it on the new partition afterwards.
+ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- still fails
+ERROR: cannot merge or split partition "tp_0_2" that has row-level security policies
+DETAIL: The policies are not carried over to the new partition and would be silently lost.
+HINT: Drop the policies from the partition before the operation, and define them on the new partition afterwards.
+DROP POLICY hide_secret ON tp_0_2;
+-- Row security on the partitioned table alone is fine.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP TABLE t;
RESET search_path;
--
DROP SCHEMA partition_split_schema;
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 562fcb3401b..0fcda645147 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -937,6 +937,27 @@ ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
DROP PUBLICATION pub_merge;
DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition with row-level security of its own: it
+-- is not carried over, so the new partition would expose rows the merged
+-- partitions hide. Policies defined while row security is disabled are
+-- rejected too, as they would be lost without a trace.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_1 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ALTER TABLE tp_0_1 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- still fails
+DROP POLICY hide_secret ON tp_0_1;
+-- Row security on the partitioned table alone is fine: the partitions have
+-- none of their own, so nothing is lost.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP TABLE t;
+
+
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index db383c1ff30..e97f13f749c 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1335,6 +1335,28 @@ ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
DROP PUBLICATION pub_split;
DROP TABLE t;
+-- SPLIT PARTITION rejects a partition with row-level security of its own, for
+-- the same reason as MERGE.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- still fails
+DROP POLICY hide_secret ON tp_0_2;
+-- Row security on the partitioned table alone is fine.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP TABLE t;
+
RESET search_path;
--
--
2.55.0
[application/octet-stream] v4-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch (52.4K, ../../CAPpHfdvgCQhFMqtqQ6aVbT03kqp13HAC4qHZ64vgc4OnT7TRgA@mail.gmail.com/5-v4-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch)
download | inline diff:
From e5f4908f14a1f0e6c0ec50e900089696f67ebf0b Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:19:19 +0200
Subject: [PATCH v4 3/4] Don't recalculate generated columns during MERGE/SPLIT
PARTITION(S)
ALTER TABLE ... MERGE/SPLIT PARTITION unconditionally recomputed every stored
generated column of the moved rows using the partitioned table's generation
expression. When a leaf partition's generation expression -- or a function it
calls -- differed from the partitioned table's, this silently rewrote
already-stored values, and could even break constraints.
Relocating a row between partitions never changes a user column, so a stored
generated column defined over user columns yields the same value; move it as-is
instead of recomputing, as every other command preserves generated column
values. This alone removes the silent data changes and constraint violations
reported for such columns.
Moving values as-is is only correct when the source partition's generation
expression matches the partitioned table's. A partition can carry a different
expression (ATTACH PARTITION requires the generated-column kind to match but
does not compare the expressions), in which case the moved-as-is value would not
match the new partition's generation expression -- silently storing data
inconsistent with the schema, and possibly violating NOT NULL, CHECK, or
foreign-key constraints. Reject MERGE/SPLIT in that case, in the new
checkPartitionGenExprMatchesParent().
What does legitimately change on the move is tableoid, the only system column
allowed in such expressions, so the new checkPartitionSystemColumnRefs() rejects
every dependency on it:
- A stored generated column would have to be recomputed, but unlike a normal
insert the row-movement path does not re-verify NOT NULL, foreign-key, or
generated-column-dependent CHECK constraints, so a recomputed value could
silently violate them. A virtual generated column is not stored at all, so
its value would silently change as soon as the rows live in the new
partition, with the same consequences.
- A CHECK constraint would have to be re-verified against the new partition's
OID, and that cannot be done faithfully either: the row movement runs under
RestrictSearchPath(), so a search_path dependent expression such as
tableoid::regclass::text does not evaluate the way it would for a regular
INSERT, which makes the re-verification both unreliable and confusing.
As nothing is recomputed or re-verified anymore, the machinery that did so
during the row move is gone: createTableConstraints() no longer records
generated columns in AlteredTableInfo.newvals nor CHECK constraints in
AlteredTableInfo.constraints, and the two row-move helpers that evaluated them
are removed, along with the work queue entry and arguments that only existed to
carry them.
Document the behavior and add regression coverage for all three rejections.
Existing MERGE/SPLIT tests that relied on recomputation now assert the rejection
or use a generation expression matching the partitioned table, and a
function-change test shows a plain generated column's value preserved.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 30 ++
src/backend/commands/tablecmds.c | 397 +++++++++---------
src/test/regress/expected/partition_merge.out | 127 ++++--
src/test/regress/expected/partition_split.out | 74 +++-
src/test/regress/sql/partition_merge.sql | 88 +++-
src/test/regress/sql/partition_split.sql | 55 ++-
6 files changed, 486 insertions(+), 285 deletions(-)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 73e1be7dec8..0acaa23083b 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Stored generated columns keep their existing values; the merge does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the merge is
+ rejected if a merged partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partition's
+ stored data inconsistent with its own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row-movement path cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partition takes its replica identity from the merged partitions
when they all use the same simple setting
@@ -1420,6 +1435,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Stored generated columns keep their existing values; the split does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the split is
+ rejected if the split partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partitions'
+ stored data inconsistent with their own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row-movement path cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partitions take their replica identity from the split partition,
unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 1749ad68f68..923794dbd5e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -22859,92 +22859,6 @@ GetAttributeStorage(Oid atttypid, const char *storagemode)
return cstorage;
}
-/*
- * buildExpressionExecutionStates: build the needed expression execution states
- * for new partition (newPartRel) checks and initialize expressions for
- * generated columns. All expressions should be created in "tab"
- * (AlteredTableInfo structure).
- */
-static void
-buildExpressionExecutionStates(AlteredTableInfo *tab, Relation newPartRel, EState *estate)
-{
- /*
- * Build the needed expression execution states. Here, we expect only NOT
- * NULL and CHECK constraint.
- */
- foreach_ptr(NewConstraint, con, tab->constraints)
- {
- switch (con->contype)
- {
- case CONSTR_CHECK:
-
- /*
- * We already expanded virtual expression in
- * createTableConstraints.
- */
- con->qualstate = ExecPrepareExpr((Expr *) con->qual, estate);
- break;
- case CONSTR_NOTNULL:
- /* Nothing to do here. */
- break;
- default:
- elog(ERROR, "unrecognized constraint type: %d",
- (int) con->contype);
- }
- }
-
- /* Expression already planned in createTableConstraints */
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- ex->exprstate = ExecInitExpr((Expr *) ex->expr, NULL);
-}
-
-/*
- * evaluateGeneratedExpressionsAndCheckConstraints: evaluate any generated
- * expressions for "tab" (AlteredTableInfo structure) whose inputs come from
- * the new tuple (insertslot) of the new partition (newPartRel).
- */
-static void
-evaluateGeneratedExpressionsAndCheckConstraints(AlteredTableInfo *tab,
- Relation newPartRel,
- TupleTableSlot *insertslot,
- ExprContext *econtext)
-{
- econtext->ecxt_scantuple = insertslot;
-
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- {
- if (!ex->is_generated)
- continue;
-
- insertslot->tts_values[ex->attnum - 1]
- = ExecEvalExpr(ex->exprstate,
- econtext,
- &insertslot->tts_isnull[ex->attnum - 1]);
- }
-
- foreach_ptr(NewConstraint, con, tab->constraints)
- {
- switch (con->contype)
- {
- case CONSTR_CHECK:
- if (!ExecCheck(con->qualstate, econtext))
- ereport(ERROR,
- errcode(ERRCODE_CHECK_VIOLATION),
- errmsg("check constraint \"%s\" of relation \"%s\" is violated by some row",
- con->name, RelationGetRelationName(newPartRel)),
- errtableconstraint(newPartRel, con->name));
- break;
- case CONSTR_NOTNULL:
- case CONSTR_FOREIGN:
- /* Nothing to do here */
- break;
- default:
- elog(ERROR, "unrecognized constraint type: %d",
- (int) con->contype);
- }
- }
-}
-
/*
* getAttributesList: build a list of columns (ColumnDef) based on parent_rel
*/
@@ -22995,15 +22909,171 @@ getAttributesList(Relation parent_rel)
return colList;
}
+/*
+ * expression_references_system_column: walker that returns true if the given
+ * expression references any system column (a Var with a negative attribute
+ * number, such as tableoid). Used to decide whether a stored generated column
+ * must be recomputed when a row is relocated between partitions.
+ */
+static bool
+expression_references_system_column(Node *node, void *context)
+{
+ if (node == NULL)
+ return false;
+ if (IsA(node, Var) && ((Var *) node)->varattno < 0)
+ return true;
+ return expression_tree_walker(node, expression_references_system_column,
+ context);
+}
+
+/*
+ * checkPartitionSystemColumnRefs: reject MERGE/SPLIT PARTITION when the
+ * partitioned table has a generated column or a CHECK constraint whose
+ * expression references a system column.
+ *
+ * Only tableoid may appear in such expressions, and it is precisely the value
+ * that changes when a row is relocated into the new partition. Neither
+ * dependency can be honored during the row movement:
+ *
+ * - A stored generated column would have to be recomputed, but the row-movement
+ * path does not re-verify NOT NULL, foreign-key, or generated-column-dependent
+ * CHECK constraints the way a normal insert does, so a recomputed value could
+ * silently violate them. A virtual generated column is not stored at all, so
+ * its value silently changes as soon as the rows live in the new partition.
+ *
+ * - A CHECK constraint would have to be re-verified against the new partition's
+ * OID. We cannot do that faithfully either: the row movement runs under
+ * RestrictSearchPath(), so a search_path-dependent expression such as
+ * tableoid::regclass::text does not evaluate the way it would for a regular
+ * INSERT, which would make the re-verification both unreliable and confusing.
+ *
+ * So reject these cases and let the user handle such columns and constraints
+ * explicitly. In the future we may implement recomputation together with a
+ * full re-validation of the affected constraints.
+ */
+static void
+checkPartitionSystemColumnRefs(Relation parent_rel)
+{
+ TupleDesc tupleDesc = RelationGetDescr(parent_rel);
+ TupleConstr *constr = tupleDesc->constr;
+
+ if (constr == NULL)
+ return;
+
+ /* Generated columns, both stored and virtual. */
+ if (constr->has_generated_stored || constr->has_generated_virtual)
+ {
+ for (AttrNumber attno = 1; attno <= tupleDesc->natts; attno++)
+ {
+ Form_pg_attribute attr = TupleDescAttr(tupleDesc, attno - 1);
+
+ if (attr->attisdropped || attr->attgenerated == '\0')
+ continue;
+
+ if (expression_references_system_column(build_generation_expression(parent_rel, attno),
+ NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a generated column depends on a system column"),
+ errdetail("Column \"%s\" of relation \"%s\" is generated from an expression that references a system column such as tableoid.",
+ NameStr(attr->attname),
+ RelationGetRelationName(parent_rel)));
+ }
+ }
+
+ /* CHECK constraints. */
+ for (int ccnum = 0; ccnum < constr->num_check; ccnum++)
+ {
+ if (expression_references_system_column(stringToNode(constr->check[ccnum].ccbin),
+ NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a check constraint depends on a system column"),
+ errdetail("Constraint \"%s\" of relation \"%s\" references a system column such as tableoid.",
+ constr->check[ccnum].ccname,
+ RelationGetRelationName(parent_rel)));
+ }
+}
+
+/*
+ * checkPartitionGenExprMatchesParent: reject MERGE/SPLIT PARTITION when a
+ * source partition has a generated column whose generation expression differs
+ * from the partitioned table's.
+ *
+ * MERGE/SPLIT PARTITION relocates rows into the new partition and copies stored
+ * generated columns as-is rather than recomputing them (see
+ * createTableConstraints()). Since the new partition is created from the
+ * partitioned table as a template, moving values as-is is only correct when the
+ * source partition's generation expression matches the partitioned table's.
+ * Otherwise the moved value would not match the new partition's generation
+ * expression, silently storing data inconsistent with the schema and possibly
+ * violating NOT NULL, CHECK, or foreign-key constraints.
+ *
+ * A partition can end up with a generation expression different from the
+ * partitioned table's via ATTACH PARTITION, which requires the generated-column
+ * kind to match but does not compare the expressions themselves (see
+ * MergeAttributesIntoExisting()).
+ */
+static void
+checkPartitionGenExprMatchesParent(Relation parent_rel, Relation partRel)
+{
+ TupleDesc parentDesc = RelationGetDescr(parent_rel);
+ TupleConstr *constr = parentDesc->constr;
+ AttrMap *attmap = NULL;
+
+ /* Nothing to compare if the partitioned table has no generated columns. */
+ if (constr == NULL ||
+ !(constr->has_generated_stored || constr->has_generated_virtual))
+ return;
+
+ for (AttrNumber parent_attno = 1; parent_attno <= parentDesc->natts;
+ parent_attno++)
+ {
+ Form_pg_attribute pattr = TupleDescAttr(parentDesc, parent_attno - 1);
+ AttrNumber child_attno;
+ Node *parentExpr;
+ Node *childExpr;
+ bool found_whole_row;
+
+ if (pattr->attisdropped || pattr->attgenerated == '\0')
+ continue;
+
+ /*
+ * Column names match between a partitioned table and its partitions,
+ * and so does the generated-column kind; only the expression can
+ * differ (all enforced/allowed by MergeAttributesIntoExisting()).
+ */
+ child_attno = get_attnum(RelationGetRelid(partRel), NameStr(pattr->attname));
+ Assert(child_attno != InvalidAttrNumber);
+
+ parentExpr = build_generation_expression(parent_rel, parent_attno);
+ childExpr = build_generation_expression(partRel, child_attno);
+
+ /* Rewrite the partition's expression into the parent's numbering. */
+ if (attmap == NULL)
+ attmap = build_attrmap_by_name(parentDesc,
+ RelationGetDescr(partRel), false);
+ childExpr = map_variable_attnos(childExpr, 1, 0, attmap,
+ InvalidOid, &found_whole_row);
+
+ if (found_whole_row || !equal(parentExpr, childExpr))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a partition's generation expression differs from the partitioned table"),
+ errdetail("Generated column \"%s\" of partition \"%s\" has a generation expression different from table \"%s\".",
+ NameStr(pattr->attname),
+ RelationGetRelationName(partRel),
+ RelationGetRelationName(parent_rel)));
+ }
+}
+
/*
* createTableConstraints:
- * create check constraints, default values, and generated values for newRel
- * based on parent_rel. tab is pending-work queue for newRel, we may need it in
- * MergePartitionsMoveRows.
+ * create check constraints and column defaults (including generation
+ * expressions) for newRel based on parent_rel.
*/
static void
-createTableConstraints(List **wqueue, AlteredTableInfo *tab,
- Relation parent_rel, Relation newRel)
+createTableConstraints(Relation parent_rel, Relation newRel)
{
TupleDesc tupleDesc;
TupleConstr *constr;
@@ -23045,7 +23115,6 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
bool found_whole_row;
AttrNumber num;
Node *def;
- NewColumnValue *newval;
if (attribute->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
this_default = build_generation_expression(parent_rel, attribute->attnum);
@@ -23067,19 +23136,18 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
StoreAttrDefault(newRel, num, def, false);
/*
- * Stored generated column expressions in parent_rel might
- * reference the tableoid. newRel, parent_rel tableoid clear is
- * not the same. If so, these stored generated columns require
- * recomputation for newRel within MergePartitionsMoveRows.
+ * Relocating a row between partitions never changes a user
+ * column, so a stored generated column defined over user columns
+ * keeps the same value; we move it as-is rather than recomputing
+ * it, which is what every other command does. (A source
+ * partition whose generation expression differs from the
+ * partitioned table's has already been rejected by
+ * checkPartitionGenExprMatchesParent(), and an expression
+ * depending on a system column by
+ * checkPartitionSystemColumnRefs(); moving as-is here also avoids
+ * silently rewriting stored data when a function the expression
+ * calls has since been redefined.)
*/
- if (attribute->attgenerated == ATTRIBUTE_GENERATED_STORED)
- {
- newval = palloc0_object(NewColumnValue);
- newval->attnum = num;
- newval->expr = expression_planner((Expr *) def);
- newval->is_generated = (attribute->attgenerated != '\0');
- tab->newvals = lappend(tab->newvals, newval);
- }
}
}
@@ -23138,40 +23206,13 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
CommandCounterIncrement();
/*
- * parent_rel check constraint expression may reference tableoid, so later
- * in MergePartitionsMoveRows, we need to evaluate the check constraint
- * again for the newRel. We can check whether the check constraint
- * contains a tableoid reference via pull_varattnos.
+ * The relocated rows satisfy the new partition's CHECK constraints
+ * without any re-verification here: the constraints are copied from the
+ * partitioned table, which the source partitions already inherited, and
+ * the row movement changes no column value. Constraints depending on a
+ * system column, the one thing that does change, were rejected by
+ * checkPartitionSystemColumnRefs().
*/
- foreach_ptr(CookedConstraint, ccon, cookedConstraints)
- {
- if (!ccon->skip_validation)
- {
- Node *qual;
- Bitmapset *attnums = NULL;
-
- Assert(ccon->contype == CONSTR_CHECK);
- qual = expand_generated_columns_in_expr(ccon->expr, newRel, 1);
- pull_varattnos(qual, 1, &attnums);
-
- /*
- * Add a check only if it contains a tableoid
- * (TableOidAttributeNumber).
- */
- if (bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber,
- attnums))
- {
- NewConstraint *newcon;
-
- newcon = palloc0_object(NewConstraint);
- newcon->name = ccon->name;
- newcon->contype = CONSTR_CHECK;
- newcon->qual = qual;
-
- tab->constraints = lappend(tab->constraints, newcon);
- }
- }
- }
/* Don't need the cookedConstraints anymore. */
list_free_deep(cookedConstraints);
@@ -23209,7 +23250,7 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
* Returns the created relation (locked in AccessExclusiveLock mode).
*/
static Relation
-createPartitionTable(List **wqueue, RangeVar *newPartName,
+createPartitionTable(RangeVar *newPartName,
Relation parent_rel, Oid ownerId)
{
Relation newRel;
@@ -23220,7 +23261,6 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
List *colList = NIL;
Oid relamId;
Oid namespaceId;
- AlteredTableInfo *new_partrel_tab;
Form_pg_class parent_relform = parent_rel->rd_rel;
/* If the existing rel is temp, it must belong to this session. */
@@ -23339,11 +23379,8 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
*/
newRel = table_open(newRelId, NoLock);
- /* Find or create a work queue entry for the newly created table. */
- new_partrel_tab = ATGetQueueEntry(wqueue, newRel);
-
/* Create constraints, default values, and generated values. */
- createTableConstraints(wqueue, new_partrel_tab, parent_rel, newRel);
+ createTableConstraints(parent_rel, newRel);
/*
* Need to call CommandCounterIncrement, so a fresh relcache entry has
@@ -23488,7 +23525,7 @@ transferPartitionReplicaIdentity(List *sourceOids, List *newPartRels)
/*
* MergePartitionsMoveRows: scan partitions to be merged (mergingPartitions)
* of the partitioned table and move rows into the new partition
- * (newPartRel). We also verify check constraints against these rows.
+ * (newPartRel).
*/
static void
MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPartRel)
@@ -23511,14 +23548,8 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
- /* Find the work queue entry for the new partition table: newPartRel. */
- tab = ATGetQueueEntry(wqueue, newPartRel);
-
- /* Generate the constraint and default execution states. */
estate = CreateExecutorState();
- buildExpressionExecutionStates(tab, newPartRel, estate);
-
mycid = GetCurrentCommandId(true);
/* Prepare a BulkInsertState for table_tuple_insert. */
@@ -23594,22 +23625,6 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
ExecStoreVirtualTuple(insertslot);
}
- /*
- * Constraints and GENERATED expressions might reference the
- * tableoid column, so fill tts_tableOid with the desired value.
- * (We must do this each time, because it gets overwritten with
- * newrel's OID during storing.)
- */
- insertslot->tts_tableOid = RelationGetRelid(newPartRel);
-
- /*
- * Now, evaluate any generated expressions whose inputs come from
- * the new tuple. We assume these columns won't reference each
- * other, so that there's no ordering dependency.
- */
- evaluateGeneratedExpressionsAndCheckConstraints(tab, newPartRel,
- insertslot, econtext);
-
/* Write the tuple out to the new relation. */
table_tuple_insert(newPartRel, insertslot, mycid,
ti_options, bistate);
@@ -23906,6 +23921,13 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
int save_sec_context;
int save_nestlevel;
+ /*
+ * The rows are relocated as-is, but a generated column or CHECK
+ * constraint depending on a system column would change meaning in the new
+ * partition.
+ */
+ checkPartitionSystemColumnRefs(rel);
+
/*
* Check ownership of merged partitions - partitions with different owners
* cannot be merged. Also, collect the OIDs of these partitions during the
@@ -23934,6 +23956,14 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
else
ownerId = mergingPartition->rd_rel->relowner;
+ /*
+ * The new partition inherits the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data.
+ */
+ checkPartitionGenExprMatchesParent(rel, mergingPartition);
+
/* Store the next merging partition into the list. */
mergingPartitions = lappend_oid(mergingPartitions,
RelationGetRelid(mergingPartition));
@@ -24033,7 +24063,7 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
* model.
*/
Assert(OidIsValid(ownerId));
- newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ newPartRel = createPartitionTable(cmd->name, rel, ownerId);
/*
* Carry the source partitions' replica identity over to the new
@@ -24212,11 +24242,6 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
pc = createSplitPartitionContext((Relation) lfirst(listptr2));
- /* Find the work queue entry for the new partition table: newPartRel. */
- pc->tab = ATGetQueueEntry(wqueue, pc->partRel);
-
- buildExpressionExecutionStates(pc->tab, pc->partRel, estate);
-
if (sps->bound->is_default)
{
/*
@@ -24334,22 +24359,6 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
ExecStoreVirtualTuple(insertslot);
}
- /*
- * Constraints and GENERATED expressions might reference the tableoid
- * column, so fill tts_tableOid with the desired value. (We must do
- * this each time, because it gets overwritten with newrel's OID
- * during storing.)
- */
- insertslot->tts_tableOid = RelationGetRelid(pc->partRel);
-
- /*
- * Now, evaluate any generated expressions whose inputs come from the
- * new tuple. We assume these columns won't reference each other, so
- * that there's no ordering dependency.
- */
- evaluateGeneratedExpressionsAndCheckConstraints(pc->tab, pc->partRel,
- insertslot, econtext);
-
/* Write the tuple out to the new relation. */
table_tuple_insert(pc->partRel, insertslot, mycid,
ti_options, pc->bistate);
@@ -24405,6 +24414,16 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
splitRelOid = RelationGetRelid(splitRel);
+ /*
+ * The new partitions inherit the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a split partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data. Likewise reject expressions depending on a system
+ * column, whose value changes in the new partitions.
+ */
+ checkPartitionSystemColumnRefs(rel);
+ checkPartitionGenExprMatchesParent(rel, splitRel);
+
/* Check descriptions of new partitions. */
foreach_node(SinglePartitionSpec, sps, cmd->partlist)
{
@@ -24480,7 +24499,7 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
{
Relation newPartRel;
- newPartRel = createPartitionTable(wqueue, sps->name, rel,
+ newPartRel = createPartitionTable(sps->name, rel,
splitRel->rd_rel->relowner);
newPartRels = lappend(newPartRels, newPartRel);
}
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 0c19e5fa93f..10844fd9f9b 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -887,14 +887,14 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -926,7 +926,7 @@ CREATE TRIGGER tp_1_2_before_insert_row_trigger BEFORE INSERT ON tp_1_2 FOR EACH
i | integer | | not null | | plain | | | tp_0_1.i
t | text | | | 'default_tp_0_1'::text | main | | |
b | bigint | | not null | | plain | | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | | |
Partition of: t FOR VALUES FROM (0) TO (1)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 1))
Check constraints:
@@ -1030,37 +1030,50 @@ ERROR: insert or update on table "t_fk" violates foreign key constraint "t_fk_i
DETAIL: Key (i)=(2) is not present in table "t".
DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so MERGE PARTITIONS is rejected. This holds for a stored column,
+-- whose value cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partition (here that
+-- would also break the NOT NULL constraint if the new partition happened to
+-- get the OID mentioned in the expression). A generated column over user
+-- columns only is fine: its value is preserved, as exercised above.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "g" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partition's OID, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a check constraint depends on a system column
+DETAIL: Constraint "cc" of relation "t" references a system column such as tableoid.
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i * 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i * 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i * 2), i int);
ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
ALTER TABLE t ADD CHECK (g > 0);
@@ -1070,24 +1083,17 @@ ALTER TABLE t MERGE PARTITIONS (tp_1, tp_2) INTO tp_12;
INSERT INTO t VALUES (16);
-- ERROR
INSERT INTO t VALUES (0);
-ERROR: new row for relation "tp_12" violates check constraint "t_i_check"
+ERROR: new row for relation "tp_12" violates check constraint "t_g_check"
DETAIL: Failing row contains (0, virtual).
-- Should be 3 rows: (5), (15), (16):
-SELECT i FROM t ORDER BY i;
- i
-----
- 5
- 15
- 16
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+----
+ 5 | 10
+ 15 | 30
+ 16 | 32
(3 rows)
--- Should be 1 because for the same tableoid (15 + tableoid) = (5 + tableoid) + 10:
-SELECT count(*) FROM t WHERE i = 15 AND g IN (SELECT g + 10 FROM t WHERE i = 5);
- count
--------
- 1
-(1 row)
-
DROP TABLE t;
-- A merged partition needs its own TOAST table; otherwise an out-of-line
-- varlena value carried over from one of the merging partitions has
@@ -1167,6 +1173,45 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+-----
+ 3 | 6
+ 5 | 500
+ 12 | 24
+(3 rows)
+
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_10" has a generation expression different from table "t".
DROP TABLE t;
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index c086f7d2d05..9757a48464f 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1547,7 +1547,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -1576,7 +1576,7 @@ CREATE TRIGGER tp_x_before_insert_row_trigger BEFORE INSERT ON tp_x FOR EACH ROW
i | integer | | not null | | plain | | tp_x.i
t | text | | | 'default_tp_x'::text | main | |
b | bigint | | not null | | plain | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | |
Partition of: t FOR VALUES FROM (0) TO (2)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 2))
Check constraints:
@@ -1627,32 +1627,60 @@ SELECT tableoid::regclass, * FROM t ORDER BY tableoid::regclass::text COLLATE "C
DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so SPLIT PARTITION is rejected -- for a stored column, whose value
+-- cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partitions.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "g" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partitions' OIDs, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_1');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a check constraint depends on a system column
+DETAIL: Constraint "cc" of relation "t" references a system column such as tableoid.
+DROP TABLE t;
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_20" has a generation expression different from table "t".
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
-- that out-of-line varlena attributes coming from the source partition
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 9c41b252ad3..562fcb3401b 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -649,7 +649,7 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
@@ -657,7 +657,7 @@ CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
@@ -736,33 +736,49 @@ DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so MERGE PARTITIONS is rejected. This holds for a stored column,
+-- whose value cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partition (here that
+-- would also break the NOT NULL constraint if the new partition happened to
+-- get the OID mentioned in the expression). A generated column over user
+-- columns only is fine: its value is preserved, as exercised above.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partition's OID, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i * 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i * 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i * 2), i int);
ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
ALTER TABLE t ADD CHECK (g > 0);
@@ -775,9 +791,7 @@ INSERT INTO t VALUES (16);
-- ERROR
INSERT INTO t VALUES (0);
-- Should be 3 rows: (5), (15), (16):
-SELECT i FROM t ORDER BY i;
--- Should be 1 because for the same tableoid (15 + tableoid) = (5 + tableoid) + 10:
-SELECT count(*) FROM t WHERE i = 15 AND g IN (SELECT g + 10 FROM t WHERE i = 5);
+SELECT i, g FROM t ORDER BY i;
DROP TABLE t;
@@ -839,6 +853,40 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+
+
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+DROP TABLE t;
+
+
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index c470c42be71..db383c1ff30 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1122,7 +1122,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
@@ -1162,26 +1162,57 @@ DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so SPLIT PARTITION is rejected -- for a stored column, whose value
+-- cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
+
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partitions.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partitions' OIDs, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_1');
+INSERT INTO t VALUES (0), (1);
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
--
2.55.0
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-17 10:27 Alexander Korotkov <aekorotkov@gmail.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
0 siblings, 2 replies; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-17 10:27 UTC (permalink / raw)
To: Melanie Plageman <melanieplageman@gmail.com>; +Cc: Daniel Gustafsson <daniel@yesql.se>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>; jian he <jian.universality@gmail.com>
On Fri, Aug 14, 2026 at 6:49 PM Alexander Korotkov <aekorotkov@gmail.com> wrote:
> On Fri, Aug 14, 2026 at 5:51 PM Melanie Plageman
> <melanieplageman@gmail.com> wrote:
> >
> > On Fri, Aug 14, 2026 at 10:08 AM Daniel Gustafsson <daniel@yesql.se> wrote:
> > >
> > > > On 12 Aug 2026, at 22:48, Melanie Plageman <melanieplageman@gmail.com> wrote:
> > >
> > > > The user needs to add RLS to the new leaf partitions if they want the
> > > > same level of security, but I'm not sure that's intuitive.
> > >
> > > It's not, and it quite easily will leave the data without the intended
> > > protection during a window.
> > >
> > > > Also, for merging partitions, if you merge two partitions that have
> > > > the same RLS, after merging, the new merged partition doesn't have
> > > > that RLS policy -- that seems confusing too
> > >
> > > I would rank this as even more unintuitive than the previous case, as a user I
> > > would expect the new partition to have the shared policy.
> > >
> > > Could we make this safe by restricting to the cases where partitions match the
> > > parent and we can make them not drop characteristics? If we want to expand
> > > which differences can be handled in a safe manner in 20 then we can revisit,
> > > rather than being very lax now and try to restrict later.
> >
> > Yes, I don't think it makes sense to silently drop the properties in
> > 19 and then start automatically propagating them in 20. That seems
> > like it will be really confusing for users that have scripts to, for
> > example, recreate ACLs for the merged or split partition(s) when using
> > 19.
>
> I agree that this kind of changing behavior is not acceptable. My
> proposal is to reject partitions with row-level security/policies for
> 19. Then we could add automatic copy of row-level security/policies
> for 20. If changing one behavior to another incompatible behavior is
> not acceptable, but changing from ERRCODE_FEATURE_NOT_SUPPORTED to new
> behavior seems acceptable (new releases support more features). Or
> alternatively we could add copying of row-level security/policies as
> an option in SQL statement for 20.
>
> SPLIT/MERGE partition(s) seemed like not so complex feature, but many
> aspects like this arise. It would be nice if we could come with some
> restricted version for 19, and expand it for 20 and later releases
> (rather than re-trying large patchset for 20).
>
> Attached 0004 implements check that source partition doesn't have
> ow-level security/policies.
>
> 0003 also have integrated edits proposed by Zsolt [1].
Any objections to pushing these 4 fixes?
------
Regards,
Alexander Korotkov
Supabase
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-17 10:31 Zsolt Parragi <zsolt.parragi@percona.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
1 sibling, 2 replies; 40+ messages in thread
From: Zsolt Parragi @ 2026-08-17 10:31 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, Melanie Plageman <melanieplageman@gmail.com>; Daniel Gustafsson <daniel@yesql.se>
> Any objections to pushing these 4 fixes?
The patches look good to me, my only question (and I think this was
the generic suggestion above) is that we should treat every divergence
the way 0004 does the RLS policies.
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-17 10:35 Daniel Gustafsson <daniel@yesql.se>
parent: Zsolt Parragi <zsolt.parragi@percona.com>
1 sibling, 0 replies; 40+ messages in thread
From: Daniel Gustafsson @ 2026-08-17 10:35 UTC (permalink / raw)
To: Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: Alexander Korotkov <aekorotkov@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Melanie Plageman <melanieplageman@gmail.com>
> On 17 Aug 2026, at 12:31, Zsolt Parragi <zsolt.parragi@percona.com> wrote:
> my only question (and I think this was the generic suggestion above) is that we
> should treat every divergence the way 0004 does the RLS policies.
That would be my preference.
--
Daniel Gustafsson
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-18 03:47 jian he <jian.universality@gmail.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
1 sibling, 1 reply; 40+ messages in thread
From: jian he @ 2026-08-18 03:47 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: Melanie Plageman <melanieplageman@gmail.com>; Daniel Gustafsson <daniel@yesql.se>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>
On Mon, Aug 17, 2026 at 6:27 PM Alexander Korotkov <aekorotkov@gmail.com> wrote:
>
> Any objections to pushing these 4 fixes?
As mentioned in [1], RestrictSearchPath is called before
MergePartitionsMoveRows and SplitPartitionMoveRows.
This means that if an expression contains anything non-immutable, we can't
evaluate it consistently for every row under a restricted search path,
imagine function text_regclass.
It would be better to add a comment directly above SplitPartitionMoveRows and
MergePartitionsMoveRows to mention this situation.
This will help future readers understand the implications.
In MergePartitionsMoveRows, the `foreach(ltab, *wqueue)` can be removed,
because ATExecMergePartitions->createPartitionTable doesn't call
ATGetQueueEntry.
Similarly, the foreach loop in deleteSplitPartitionContext can also be
removed for the same reason,
we can probably get rid of deleteSplitPartitionContext.
[1]: https://www.postgresql.org/message-id/CACJufxHk0F%2B1UyvExHoMfBZrsUeGQiB8MBm1PC5Fd3MtAszLGw%40mail.g...
--
jian
https://www.enterprisedb.com/
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-19 11:31 Alexander Korotkov <aekorotkov@gmail.com>
parent: Zsolt Parragi <zsolt.parragi@percona.com>
1 sibling, 0 replies; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-19 11:31 UTC (permalink / raw)
To: Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: pgsql-hackers@lists.postgresql.org, Melanie Plageman <melanieplageman@gmail.com>; Daniel Gustafsson <daniel@yesql.se>
On Mon, Aug 17, 2026 at 1:31 PM Zsolt Parragi <zsolt.parragi@percona.com> wrote:
>
> > Any objections to pushing these 4 fixes?
>
> The patches look good to me, my only question (and I think this was
> the generic suggestion above) is that we should treat every divergence
> the way 0004 does the RLS policies.
As I mentioned in [1], I think this is the way to save this feature
for pg19. I think it's too late to introduce new (and debatable)
functionality.
Links.
1. https://www.postgresql.org/message-id/CAPpHfdvgCQhFMqtqQ6aVbT03kqp13HAC4qHZ64vgc4OnT7TRgA%40mail.gma...
------
Regards,
Alexander Korotkov
Supabase
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-19 11:58 Alexander Korotkov <aekorotkov@gmail.com>
parent: jian he <jian.universality@gmail.com>
0 siblings, 1 reply; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-19 11:58 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: Melanie Plageman <melanieplageman@gmail.com>; Daniel Gustafsson <daniel@yesql.se>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>
On Tue, Aug 18, 2026 at 6:48 AM jian he <jian.universality@gmail.com> wrote:
>
> On Mon, Aug 17, 2026 at 6:27 PM Alexander Korotkov <aekorotkov@gmail.com> wrote:
> >
> > Any objections to pushing these 4 fixes?
>
> As mentioned in [1], RestrictSearchPath is called before
> MergePartitionsMoveRows and SplitPartitionMoveRows.
> This means that if an expression contains anything non-immutable, we can't
> evaluate it consistently for every row under a restricted search path,
> imagine function text_regclass.
> It would be better to add a comment directly above SplitPartitionMoveRows and
> MergePartitionsMoveRows to mention this situation.
> This will help future readers understand the implications.
>
> In MergePartitionsMoveRows, the `foreach(ltab, *wqueue)` can be removed,
> because ATExecMergePartitions->createPartitionTable doesn't call
> ATGetQueueEntry.
> Similarly, the foreach loop in deleteSplitPartitionContext can also be
> removed for the same reason,
> we can probably get rid of deleteSplitPartitionContext.
>
> [1]: https://www.postgresql.org/message-id/CACJufxHk0F%2B1UyvExHoMfBZrsUeGQiB8MBm1PC5Fd3MtAszLGw%40mail.g...
Agree on your corrections expect for deleteSplitPartitionContext(): it
still have resources to free. The revised patchset is attached.
------
Regards,
Alexander Korotkov
Supabase
Attachments:
[application/octet-stream] v5-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch (11.2K, ../../CAPpHfdv=7MpwkS-n_ECzdM0C9WmpNjNG68mr3gyhAu2qADp9Yg@mail.gmail.com/2-v5-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch)
download | inline diff:
From bd263258875d389d246e3e89c97824c6575f388c Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:08:36 +0200
Subject: [PATCH v5 1/4] Don't logically decode MERGE/SPLIT PARTITION row
movement
ALTER TABLE ... MERGE/SPLIT PARTITION relocates rows between partitions of the
same partitioned table by re-inserting them into the freshly created
partition(s), using plain heap inserts. Logical decoding emitted those as
INSERTs into the new partition with no matching DELETEs for the source rows,
which corrupts logical replication subscribers.
Pass TABLE_INSERT_NO_LOGICAL to the movers so the relocation is not decoded,
just as CLUSTER and VACUUM FULL already do for their rewrites. MERGE/SPLIT
PARTITION is a schema change that is not itself replicated, and the moved rows
still exist on subscribers, so suppressing the inserts keeps them consistent.
Document the behavior in the MERGE PARTITIONS and SPLIT PARTITION
commands descriptions, and add a test_decoding regression test.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
contrib/test_decoding/Makefile | 3 +-
.../expected/partition_merge_split.out | 56 +++++++++++++++++++
contrib/test_decoding/meson.build | 1 +
.../sql/partition_merge_split.sql | 34 +++++++++++
doc/src/sgml/ref/alter_table.sgml | 30 ++++++++++
src/backend/commands/tablecmds.c | 20 +++++--
6 files changed, 139 insertions(+), 5 deletions(-)
create mode 100644 contrib/test_decoding/expected/partition_merge_split.out
create mode 100644 contrib/test_decoding/sql/partition_merge_split.sql
diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile
index 0111124399a..ab90cd7fec2 100644
--- a/contrib/test_decoding/Makefile
+++ b/contrib/test_decoding/Makefile
@@ -5,7 +5,8 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin"
REGRESS = ddl xact rewrite toast permissions decoding_in_xact \
decoding_into_rel binary prepared replorigin time messages \
- repack spill slot truncate stream stats twophase twophase_stream
+ repack spill slot truncate stream stats twophase twophase_stream \
+ partition_merge_split
ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \
oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \
twophase_snapshot slot_creation_error catalog_change_snapshot \
diff --git a/contrib/test_decoding/expected/partition_merge_split.out b/contrib/test_decoding/expected/partition_merge_split.out
new file mode 100644
index 00000000000..63ec5af98d0
--- /dev/null
+++ b/contrib/test_decoding/expected/partition_merge_split.out
@@ -0,0 +1,56 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+ ?column?
+----------
+ init
+(1 row)
+
+INSERT INTO part VALUES (1), (11);
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ count
+-------
+ 4
+(1 row)
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+--------------------------------------------
+ BEGIN
+ table public.part_1: INSERT: id[integer]:2
+ COMMIT
+(3 rows)
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+ ?column?
+----------
+ stop
+(1 row)
+
+DROP TABLE part;
diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build
index ac655853d26..a504bc00794 100644
--- a/contrib/test_decoding/meson.build
+++ b/contrib/test_decoding/meson.build
@@ -42,6 +42,7 @@ tests += {
'stats',
'twophase',
'twophase_stream',
+ 'partition_merge_split',
],
'regress_args': [
'--temp-config', files('logical.conf'),
diff --git a/contrib/test_decoding/sql/partition_merge_split.sql b/contrib/test_decoding/sql/partition_merge_split.sql
new file mode 100644
index 00000000000..efdd6019ebd
--- /dev/null
+++ b/contrib/test_decoding/sql/partition_merge_split.sql
@@ -0,0 +1,34 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+
+INSERT INTO part VALUES (1), (11);
+
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+DROP TABLE part;
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..b8246a7ee48 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Moving rows into the new partition does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... MERGE PARTITIONS</command> is a schema change and
+ is not itself replicated to logical replication subscribers. If changes
+ are published for the partitioned table itself (see
+ <literal>publish_via_partition_root</literal>), subscribers are unaffected
+ and may keep their own partition layout. Otherwise changes are published
+ for the individual partitions, and the new partition is not part of the
+ subscription until the subscription is refreshed; changes made to it in
+ the meantime are not applied, so refreshing without copying its data would
+ silently lose them.
+ </para>
+
<note>
<para>
Merging partitions acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
@@ -1386,6 +1401,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Moving rows into the new partitions does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... SPLIT PARTITION</command> is a schema change and
+ is not itself replicated to logical replication subscribers. If changes
+ are published for the partitioned table itself (see
+ <literal>publish_via_partition_root</literal>), subscribers are unaffected
+ and may keep their own partition layout. Otherwise changes are published
+ for the individual partitions, and the new partitions are not part of the
+ subscription until the subscription is refreshed; changes made to them in
+ the meantime are not applied, so refreshing without copying their data
+ would silently lose them.
+ </para>
+
<note>
<para>
Split partition acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 766f8985479..351415dafc3 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23423,8 +23423,16 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
AlteredTableInfo *tab;
ListCell *ltab;
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Also suppress logical
+ * decoding of these inserts: merging partitions physically relocates rows
+ * within the same partitioned table, much like CLUSTER or VACUUM FULL.
+ * The relocation is not a user-level INSERT, and MERGE PARTITIONS is DDL
+ * that logical replication does not replicate anyway; emitting INSERTs
+ * for the moved rows (with no matching DELETEs for the source rows) would
+ * corrupt logical subscribers.
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
@@ -24091,8 +24099,12 @@ static void
SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
List *partlist, List *newPartRels)
{
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Suppress logical decoding
+ * of these inserts as well; see the matching comment in
+ * MergePartitionsMoveRows().
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
CommandId mycid;
EState *estate;
ListCell *listptr,
--
2.55.0
[application/octet-stream] v5-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch (56.6K, ../../CAPpHfdv=7MpwkS-n_ECzdM0C9WmpNjNG68mr3gyhAu2qADp9Yg@mail.gmail.com/3-v5-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch)
download | inline diff:
From 47cce8a369afc5cd3f4276a23cd7822c6594363d Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:19:19 +0200
Subject: [PATCH v5 3/4] Don't recalculate generated columns during MERGE/SPLIT
PARTITION(S)
ALTER TABLE ... MERGE/SPLIT PARTITION unconditionally recomputed every stored
generated column of the moved rows using the partitioned table's generation
expression. When a leaf partition's generation expression -- or a function it
calls -- differed from the partitioned table's, this silently rewrote
already-stored values, and could even break constraints.
Relocating a row between partitions never changes a user column, so a stored
generated column defined over user columns yields the same value; move it as-is
instead of recomputing, as every other command preserves generated column
values. This alone removes the silent data changes and constraint violations
reported for such columns.
Moving values as-is is only correct when the source partition's generation
expression matches the partitioned table's. A partition can carry a different
expression (ATTACH PARTITION requires the generated-column kind to match but
does not compare the expressions), in which case the moved-as-is value would not
match the new partition's generation expression -- silently storing data
inconsistent with the schema, and possibly violating NOT NULL, CHECK, or
foreign-key constraints. Reject MERGE/SPLIT in that case, in the new
checkPartitionGenExprMatchesParent().
What does legitimately change on the move is tableoid, the only system column
allowed in such expressions, so the new checkPartitionSystemColumnRefs() rejects
every dependency on it:
- A stored generated column would have to be recomputed, but unlike a normal
insert the row-movement path does not re-verify NOT NULL, foreign-key, or
generated-column-dependent CHECK constraints, so a recomputed value could
silently violate them. A virtual generated column is not stored at all, so
its value would silently change as soon as the rows live in the new
partition, with the same consequences.
- A CHECK constraint would have to be re-verified against the new partition's
OID, and that cannot be done faithfully either: the row movement runs under
RestrictSearchPath(), so a search_path dependent expression such as
tableoid::regclass::text does not evaluate the way it would for a regular
INSERT, which makes the re-verification both unreliable and confusing.
As nothing is recomputed or re-verified anymore, the machinery that did so
during the row move is gone: createTableConstraints() no longer records
generated columns in AlteredTableInfo.newvals nor CHECK constraints in
AlteredTableInfo.constraints, and the two row-move helpers that evaluated them
are removed, along with the work queue entry and arguments that only existed to
carry them. Since nothing creates a work queue entry for the new partitions
anymore, the loops that deleted it again go away too.
Note in both row-move functions that they run under a restricted search path,
so that whatever gets evaluated there in the future is held to that.
Document the behavior and add regression coverage for all three rejections.
Existing MERGE/SPLIT tests that relied on recomputation now assert the rejection
or use a generation expression matching the partitioned table, and a
function-change test shows a plain generated column's value preserved.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 30 ++
src/backend/commands/tablecmds.c | 455 +++++++++---------
src/test/regress/expected/partition_merge.out | 127 +++--
src/test/regress/expected/partition_split.out | 74 ++-
src/test/regress/sql/partition_merge.sql | 88 +++-
src/test/regress/sql/partition_split.sql | 55 ++-
6 files changed, 505 insertions(+), 324 deletions(-)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 73e1be7dec8..0acaa23083b 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Stored generated columns keep their existing values; the merge does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the merge is
+ rejected if a merged partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partition's
+ stored data inconsistent with its own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row-movement path cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partition takes its replica identity from the merged partitions
when they all use the same simple setting
@@ -1420,6 +1435,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Stored generated columns keep their existing values; the split does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the split is
+ rejected if the split partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partitions'
+ stored data inconsistent with their own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row-movement path cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partitions take their replica identity from the split partition,
unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f8805a5980f..24ffe257b58 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -22916,92 +22916,6 @@ GetAttributeStorage(Oid atttypid, const char *storagemode)
return cstorage;
}
-/*
- * buildExpressionExecutionStates: build the needed expression execution states
- * for new partition (newPartRel) checks and initialize expressions for
- * generated columns. All expressions should be created in "tab"
- * (AlteredTableInfo structure).
- */
-static void
-buildExpressionExecutionStates(AlteredTableInfo *tab, Relation newPartRel, EState *estate)
-{
- /*
- * Build the needed expression execution states. Here, we expect only NOT
- * NULL and CHECK constraint.
- */
- foreach_ptr(NewConstraint, con, tab->constraints)
- {
- switch (con->contype)
- {
- case CONSTR_CHECK:
-
- /*
- * We already expanded virtual expression in
- * createTableConstraints.
- */
- con->qualstate = ExecPrepareExpr((Expr *) con->qual, estate);
- break;
- case CONSTR_NOTNULL:
- /* Nothing to do here. */
- break;
- default:
- elog(ERROR, "unrecognized constraint type: %d",
- (int) con->contype);
- }
- }
-
- /* Expression already planned in createTableConstraints */
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- ex->exprstate = ExecInitExpr((Expr *) ex->expr, NULL);
-}
-
-/*
- * evaluateGeneratedExpressionsAndCheckConstraints: evaluate any generated
- * expressions for "tab" (AlteredTableInfo structure) whose inputs come from
- * the new tuple (insertslot) of the new partition (newPartRel).
- */
-static void
-evaluateGeneratedExpressionsAndCheckConstraints(AlteredTableInfo *tab,
- Relation newPartRel,
- TupleTableSlot *insertslot,
- ExprContext *econtext)
-{
- econtext->ecxt_scantuple = insertslot;
-
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- {
- if (!ex->is_generated)
- continue;
-
- insertslot->tts_values[ex->attnum - 1]
- = ExecEvalExpr(ex->exprstate,
- econtext,
- &insertslot->tts_isnull[ex->attnum - 1]);
- }
-
- foreach_ptr(NewConstraint, con, tab->constraints)
- {
- switch (con->contype)
- {
- case CONSTR_CHECK:
- if (!ExecCheck(con->qualstate, econtext))
- ereport(ERROR,
- errcode(ERRCODE_CHECK_VIOLATION),
- errmsg("check constraint \"%s\" of relation \"%s\" is violated by some row",
- con->name, RelationGetRelationName(newPartRel)),
- errtableconstraint(newPartRel, con->name));
- break;
- case CONSTR_NOTNULL:
- case CONSTR_FOREIGN:
- /* Nothing to do here */
- break;
- default:
- elog(ERROR, "unrecognized constraint type: %d",
- (int) con->contype);
- }
- }
-}
-
/*
* getAttributesList: build a list of columns (ColumnDef) based on parent_rel
*/
@@ -23052,15 +22966,171 @@ getAttributesList(Relation parent_rel)
return colList;
}
+/*
+ * expression_references_system_column: walker that returns true if the given
+ * expression references any system column (a Var with a negative attribute
+ * number, such as tableoid). Used to decide whether a stored generated column
+ * must be recomputed when a row is relocated between partitions.
+ */
+static bool
+expression_references_system_column(Node *node, void *context)
+{
+ if (node == NULL)
+ return false;
+ if (IsA(node, Var) && ((Var *) node)->varattno < 0)
+ return true;
+ return expression_tree_walker(node, expression_references_system_column,
+ context);
+}
+
+/*
+ * checkPartitionSystemColumnRefs: reject MERGE/SPLIT PARTITION when the
+ * partitioned table has a generated column or a CHECK constraint whose
+ * expression references a system column.
+ *
+ * Only tableoid may appear in such expressions, and it is precisely the value
+ * that changes when a row is relocated into the new partition. Neither
+ * dependency can be honored during the row movement:
+ *
+ * - A stored generated column would have to be recomputed, but the row-movement
+ * path does not re-verify NOT NULL, foreign-key, or generated-column-dependent
+ * CHECK constraints the way a normal insert does, so a recomputed value could
+ * silently violate them. A virtual generated column is not stored at all, so
+ * its value silently changes as soon as the rows live in the new partition.
+ *
+ * - A CHECK constraint would have to be re-verified against the new partition's
+ * OID. We cannot do that faithfully either: the row movement runs under
+ * RestrictSearchPath(), so a search_path-dependent expression such as
+ * tableoid::regclass::text does not evaluate the way it would for a regular
+ * INSERT, which would make the re-verification both unreliable and confusing.
+ *
+ * So reject these cases and let the user handle such columns and constraints
+ * explicitly. In the future we may implement recomputation together with a
+ * full re-validation of the affected constraints.
+ */
+static void
+checkPartitionSystemColumnRefs(Relation parent_rel)
+{
+ TupleDesc tupleDesc = RelationGetDescr(parent_rel);
+ TupleConstr *constr = tupleDesc->constr;
+
+ if (constr == NULL)
+ return;
+
+ /* Generated columns, both stored and virtual. */
+ if (constr->has_generated_stored || constr->has_generated_virtual)
+ {
+ for (AttrNumber attno = 1; attno <= tupleDesc->natts; attno++)
+ {
+ Form_pg_attribute attr = TupleDescAttr(tupleDesc, attno - 1);
+
+ if (attr->attisdropped || attr->attgenerated == '\0')
+ continue;
+
+ if (expression_references_system_column(build_generation_expression(parent_rel, attno),
+ NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a generated column depends on a system column"),
+ errdetail("Column \"%s\" of relation \"%s\" is generated from an expression that references a system column such as tableoid.",
+ NameStr(attr->attname),
+ RelationGetRelationName(parent_rel)));
+ }
+ }
+
+ /* CHECK constraints. */
+ for (int ccnum = 0; ccnum < constr->num_check; ccnum++)
+ {
+ if (expression_references_system_column(stringToNode(constr->check[ccnum].ccbin),
+ NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a check constraint depends on a system column"),
+ errdetail("Constraint \"%s\" of relation \"%s\" references a system column such as tableoid.",
+ constr->check[ccnum].ccname,
+ RelationGetRelationName(parent_rel)));
+ }
+}
+
+/*
+ * checkPartitionGenExprMatchesParent: reject MERGE/SPLIT PARTITION when a
+ * source partition has a generated column whose generation expression differs
+ * from the partitioned table's.
+ *
+ * MERGE/SPLIT PARTITION relocates rows into the new partition and copies stored
+ * generated columns as-is rather than recomputing them (see
+ * createTableConstraints()). Since the new partition is created from the
+ * partitioned table as a template, moving values as-is is only correct when the
+ * source partition's generation expression matches the partitioned table's.
+ * Otherwise the moved value would not match the new partition's generation
+ * expression, silently storing data inconsistent with the schema and possibly
+ * violating NOT NULL, CHECK, or foreign-key constraints.
+ *
+ * A partition can end up with a generation expression different from the
+ * partitioned table's via ATTACH PARTITION, which requires the generated-column
+ * kind to match but does not compare the expressions themselves (see
+ * MergeAttributesIntoExisting()).
+ */
+static void
+checkPartitionGenExprMatchesParent(Relation parent_rel, Relation partRel)
+{
+ TupleDesc parentDesc = RelationGetDescr(parent_rel);
+ TupleConstr *constr = parentDesc->constr;
+ AttrMap *attmap = NULL;
+
+ /* Nothing to compare if the partitioned table has no generated columns. */
+ if (constr == NULL ||
+ !(constr->has_generated_stored || constr->has_generated_virtual))
+ return;
+
+ for (AttrNumber parent_attno = 1; parent_attno <= parentDesc->natts;
+ parent_attno++)
+ {
+ Form_pg_attribute pattr = TupleDescAttr(parentDesc, parent_attno - 1);
+ AttrNumber child_attno;
+ Node *parentExpr;
+ Node *childExpr;
+ bool found_whole_row;
+
+ if (pattr->attisdropped || pattr->attgenerated == '\0')
+ continue;
+
+ /*
+ * Column names match between a partitioned table and its partitions,
+ * and so does the generated-column kind; only the expression can
+ * differ (all enforced/allowed by MergeAttributesIntoExisting()).
+ */
+ child_attno = get_attnum(RelationGetRelid(partRel), NameStr(pattr->attname));
+ Assert(child_attno != InvalidAttrNumber);
+
+ parentExpr = build_generation_expression(parent_rel, parent_attno);
+ childExpr = build_generation_expression(partRel, child_attno);
+
+ /* Rewrite the partition's expression into the parent's numbering. */
+ if (attmap == NULL)
+ attmap = build_attrmap_by_name(parentDesc,
+ RelationGetDescr(partRel), false);
+ childExpr = map_variable_attnos(childExpr, 1, 0, attmap,
+ InvalidOid, &found_whole_row);
+
+ if (found_whole_row || !equal(parentExpr, childExpr))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a partition's generation expression differs from the partitioned table"),
+ errdetail("Generated column \"%s\" of partition \"%s\" has a generation expression different from table \"%s\".",
+ NameStr(pattr->attname),
+ RelationGetRelationName(partRel),
+ RelationGetRelationName(parent_rel)));
+ }
+}
+
/*
* createTableConstraints:
- * create check constraints, default values, and generated values for newRel
- * based on parent_rel. tab is pending-work queue for newRel, we may need it in
- * MergePartitionsMoveRows.
+ * create check constraints and column defaults (including generation
+ * expressions) for newRel based on parent_rel.
*/
static void
-createTableConstraints(List **wqueue, AlteredTableInfo *tab,
- Relation parent_rel, Relation newRel)
+createTableConstraints(Relation parent_rel, Relation newRel)
{
TupleDesc tupleDesc;
TupleConstr *constr;
@@ -23102,7 +23172,6 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
bool found_whole_row;
AttrNumber num;
Node *def;
- NewColumnValue *newval;
if (attribute->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
this_default = build_generation_expression(parent_rel, attribute->attnum);
@@ -23124,19 +23193,18 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
StoreAttrDefault(newRel, num, def, false);
/*
- * Stored generated column expressions in parent_rel might
- * reference the tableoid. newRel, parent_rel tableoid clear is
- * not the same. If so, these stored generated columns require
- * recomputation for newRel within MergePartitionsMoveRows.
+ * Relocating a row between partitions never changes a user
+ * column, so a stored generated column defined over user columns
+ * keeps the same value; we move it as-is rather than recomputing
+ * it, which is what every other command does. (A source
+ * partition whose generation expression differs from the
+ * partitioned table's has already been rejected by
+ * checkPartitionGenExprMatchesParent(), and an expression
+ * depending on a system column by
+ * checkPartitionSystemColumnRefs(); moving as-is here also avoids
+ * silently rewriting stored data when a function the expression
+ * calls has since been redefined.)
*/
- if (attribute->attgenerated == ATTRIBUTE_GENERATED_STORED)
- {
- newval = palloc0_object(NewColumnValue);
- newval->attnum = num;
- newval->expr = expression_planner((Expr *) def);
- newval->is_generated = (attribute->attgenerated != '\0');
- tab->newvals = lappend(tab->newvals, newval);
- }
}
}
@@ -23195,40 +23263,13 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
CommandCounterIncrement();
/*
- * parent_rel check constraint expression may reference tableoid, so later
- * in MergePartitionsMoveRows, we need to evaluate the check constraint
- * again for the newRel. We can check whether the check constraint
- * contains a tableoid reference via pull_varattnos.
+ * The relocated rows satisfy the new partition's CHECK constraints
+ * without any re-verification here: the constraints are copied from the
+ * partitioned table, which the source partitions already inherited, and
+ * the row movement changes no column value. Constraints depending on a
+ * system column, the one thing that does change, were rejected by
+ * checkPartitionSystemColumnRefs().
*/
- foreach_ptr(CookedConstraint, ccon, cookedConstraints)
- {
- if (!ccon->skip_validation)
- {
- Node *qual;
- Bitmapset *attnums = NULL;
-
- Assert(ccon->contype == CONSTR_CHECK);
- qual = expand_generated_columns_in_expr(ccon->expr, newRel, 1);
- pull_varattnos(qual, 1, &attnums);
-
- /*
- * Add a check only if it contains a tableoid
- * (TableOidAttributeNumber).
- */
- if (bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber,
- attnums))
- {
- NewConstraint *newcon;
-
- newcon = palloc0_object(NewConstraint);
- newcon->name = ccon->name;
- newcon->contype = CONSTR_CHECK;
- newcon->qual = qual;
-
- tab->constraints = lappend(tab->constraints, newcon);
- }
- }
- }
/* Don't need the cookedConstraints anymore. */
list_free_deep(cookedConstraints);
@@ -23266,7 +23307,7 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
* Returns the created relation (locked in AccessExclusiveLock mode).
*/
static Relation
-createPartitionTable(List **wqueue, RangeVar *newPartName,
+createPartitionTable(RangeVar *newPartName,
Relation parent_rel, Oid ownerId)
{
Relation newRel;
@@ -23277,7 +23318,6 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
List *colList = NIL;
Oid relamId;
Oid namespaceId;
- AlteredTableInfo *new_partrel_tab;
Form_pg_class parent_relform = parent_rel->rd_rel;
/* If the existing rel is temp, it must belong to this session. */
@@ -23396,11 +23436,8 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
*/
newRel = table_open(newRelId, NoLock);
- /* Find or create a work queue entry for the newly created table. */
- new_partrel_tab = ATGetQueueEntry(wqueue, newRel);
-
/* Create constraints, default values, and generated values. */
- createTableConstraints(wqueue, new_partrel_tab, parent_rel, newRel);
+ createTableConstraints(parent_rel, newRel);
/*
* Need to call CommandCounterIncrement, so a fresh relcache entry has
@@ -23545,15 +23582,20 @@ transferPartitionReplicaIdentity(List *sourceOids, List *newPartRels)
/*
* MergePartitionsMoveRows: scan partitions to be merged (mergingPartitions)
* of the partitioned table and move rows into the new partition
- * (newPartRel). We also verify check constraints against these rows.
+ * (newPartRel).
+ *
+ * The caller has entered a restricted search path, so anything evaluated here
+ * does not resolve names the way the user's session would. Nothing needs
+ * evaluating for a merge: the rows are relocated unchanged, and the cases that
+ * would have required it are rejected beforehand (see
+ * checkPartitionSystemColumnRefs()). Keep it that way, or take the search
+ * path into account.
*/
static void
-MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPartRel)
+MergePartitionsMoveRows(List *mergingPartitions, Relation newPartRel)
{
CommandId mycid;
EState *estate;
- AlteredTableInfo *tab;
- ListCell *ltab;
/*
* The FSM is empty, so don't bother using it. Also suppress logical
@@ -23568,14 +23610,8 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
- /* Find the work queue entry for the new partition table: newPartRel. */
- tab = ATGetQueueEntry(wqueue, newPartRel);
-
- /* Generate the constraint and default execution states. */
estate = CreateExecutorState();
- buildExpressionExecutionStates(tab, newPartRel, estate);
-
mycid = GetCurrentCommandId(true);
/* Prepare a BulkInsertState for table_tuple_insert. */
@@ -23651,22 +23687,6 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
ExecStoreVirtualTuple(insertslot);
}
- /*
- * Constraints and GENERATED expressions might reference the
- * tableoid column, so fill tts_tableOid with the desired value.
- * (We must do this each time, because it gets overwritten with
- * newrel's OID during storing.)
- */
- insertslot->tts_tableOid = RelationGetRelid(newPartRel);
-
- /*
- * Now, evaluate any generated expressions whose inputs come from
- * the new tuple. We assume these columns won't reference each
- * other, so that there's no ordering dependency.
- */
- evaluateGeneratedExpressionsAndCheckConstraints(tab, newPartRel,
- insertslot, econtext);
-
/* Write the tuple out to the new relation. */
table_tuple_insert(newPartRel, insertslot, mycid,
ti_options, bistate);
@@ -23690,20 +23710,6 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
FreeBulkInsertState(bistate);
table_finish_bulk_insert(newPartRel, ti_options);
-
- /*
- * We don't need to process this newPartRel since we already processed it
- * here, so delete the ALTER TABLE queue for it.
- */
- foreach(ltab, *wqueue)
- {
- tab = (AlteredTableInfo *) lfirst(ltab);
- if (tab->relid == RelationGetRelid(newPartRel))
- {
- *wqueue = list_delete_cell(*wqueue, ltab);
- break;
- }
- }
}
/*
@@ -23963,6 +23969,13 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
int save_sec_context;
int save_nestlevel;
+ /*
+ * The rows are relocated as-is, but a generated column or CHECK
+ * constraint depending on a system column would change meaning in the new
+ * partition.
+ */
+ checkPartitionSystemColumnRefs(rel);
+
/*
* Check ownership of merged partitions - partitions with different owners
* cannot be merged. Also, collect the OIDs of these partitions during the
@@ -23991,6 +24004,14 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
else
ownerId = mergingPartition->rd_rel->relowner;
+ /*
+ * The new partition inherits the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data.
+ */
+ checkPartitionGenExprMatchesParent(rel, mergingPartition);
+
/* Store the next merging partition into the list. */
mergingPartitions = lappend_oid(mergingPartitions,
RelationGetRelid(mergingPartition));
@@ -24090,7 +24111,7 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
* model.
*/
Assert(OidIsValid(ownerId));
- newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ newPartRel = createPartitionTable(cmd->name, rel, ownerId);
/*
* Carry the source partitions' replica identity over to the new
@@ -24115,7 +24136,7 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
RestrictSearchPath();
/* Copy data from merged partitions to the new partition. */
- MergePartitionsMoveRows(wqueue, mergingPartitions, newPartRel);
+ MergePartitionsMoveRows(mergingPartitions, newPartRel);
/* Drop the current partitions before attaching the new one. */
foreach_oid(mergingPartitionOid, mergingPartitions)
@@ -24198,30 +24219,13 @@ createSplitPartitionContext(Relation partRel)
* deleteSplitPartitionContext: delete context for partition
*/
static void
-deleteSplitPartitionContext(SplitPartitionContext *pc, List **wqueue, uint32 ti_options)
+deleteSplitPartitionContext(SplitPartitionContext *pc, uint32 ti_options)
{
- ListCell *ltab;
-
ExecDropSingleTupleTableSlot(pc->dstslot);
FreeBulkInsertState(pc->bistate);
table_finish_bulk_insert(pc->partRel, ti_options);
- /*
- * We don't need to process this pc->partRel so delete the ALTER TABLE
- * queue of it.
- */
- foreach(ltab, *wqueue)
- {
- AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
-
- if (tab->relid == RelationGetRelid(pc->partRel))
- {
- *wqueue = list_delete_cell(*wqueue, ltab);
- break;
- }
- }
-
pfree(pc);
}
@@ -24234,9 +24238,15 @@ deleteSplitPartitionContext(SplitPartitionContext *pc, List **wqueue, uint32 ti_
* the partition specification details for all new partitions.
* newPartRels: list of Relations, new partitions created in
* ATExecSplitPartition.
+ *
+ * The caller has entered a restricted search path, so anything evaluated here
+ * does not resolve names the way the user's session would. The partition
+ * constraints checked below are safe in that respect, because functions in a
+ * partition key expression must be IMMUTABLE. Anything added here has to
+ * clear the same bar, or take the search path into account.
*/
static void
-SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
+SplitPartitionMoveRows(Relation rel, Relation splitRel,
List *partlist, List *newPartRels)
{
/*
@@ -24269,11 +24279,6 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
pc = createSplitPartitionContext((Relation) lfirst(listptr2));
- /* Find the work queue entry for the new partition table: newPartRel. */
- pc->tab = ATGetQueueEntry(wqueue, pc->partRel);
-
- buildExpressionExecutionStates(pc->tab, pc->partRel, estate);
-
if (sps->bound->is_default)
{
/*
@@ -24391,22 +24396,6 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
ExecStoreVirtualTuple(insertslot);
}
- /*
- * Constraints and GENERATED expressions might reference the tableoid
- * column, so fill tts_tableOid with the desired value. (We must do
- * this each time, because it gets overwritten with newrel's OID
- * during storing.)
- */
- insertslot->tts_tableOid = RelationGetRelid(pc->partRel);
-
- /*
- * Now, evaluate any generated expressions whose inputs come from the
- * new tuple. We assume these columns won't reference each other, so
- * that there's no ordering dependency.
- */
- evaluateGeneratedExpressionsAndCheckConstraints(pc->tab, pc->partRel,
- insertslot, econtext);
-
/* Write the tuple out to the new relation. */
table_tuple_insert(pc->partRel, insertslot, mycid,
ti_options, pc->bistate);
@@ -24427,7 +24416,7 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
FreeExecutorState(estate);
foreach_ptr(SplitPartitionContext, spc, partContexts)
- deleteSplitPartitionContext(spc, wqueue, ti_options);
+ deleteSplitPartitionContext(spc, ti_options);
}
/*
@@ -24462,6 +24451,16 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
splitRelOid = RelationGetRelid(splitRel);
+ /*
+ * The new partitions inherit the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a split partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data. Likewise reject expressions depending on a system
+ * column, whose value changes in the new partitions.
+ */
+ checkPartitionSystemColumnRefs(rel);
+ checkPartitionGenExprMatchesParent(rel, splitRel);
+
/* Check descriptions of new partitions. */
foreach_node(SinglePartitionSpec, sps, cmd->partlist)
{
@@ -24537,7 +24536,7 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
{
Relation newPartRel;
- newPartRel = createPartitionTable(wqueue, sps->name, rel,
+ newPartRel = createPartitionTable(sps->name, rel,
splitRel->rd_rel->relowner);
newPartRels = lappend(newPartRels, newPartRel);
}
@@ -24565,7 +24564,7 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
RestrictSearchPath();
/* Copy data from the split partition to the new partitions. */
- SplitPartitionMoveRows(wqueue, rel, splitRel, cmd->partlist, newPartRels);
+ SplitPartitionMoveRows(rel, splitRel, cmd->partlist, newPartRels);
/* Keep the lock until commit. */
table_close(splitRel, NoLock);
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 0c19e5fa93f..10844fd9f9b 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -887,14 +887,14 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -926,7 +926,7 @@ CREATE TRIGGER tp_1_2_before_insert_row_trigger BEFORE INSERT ON tp_1_2 FOR EACH
i | integer | | not null | | plain | | | tp_0_1.i
t | text | | | 'default_tp_0_1'::text | main | | |
b | bigint | | not null | | plain | | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | | |
Partition of: t FOR VALUES FROM (0) TO (1)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 1))
Check constraints:
@@ -1030,37 +1030,50 @@ ERROR: insert or update on table "t_fk" violates foreign key constraint "t_fk_i
DETAIL: Key (i)=(2) is not present in table "t".
DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so MERGE PARTITIONS is rejected. This holds for a stored column,
+-- whose value cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partition (here that
+-- would also break the NOT NULL constraint if the new partition happened to
+-- get the OID mentioned in the expression). A generated column over user
+-- columns only is fine: its value is preserved, as exercised above.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "g" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partition's OID, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a check constraint depends on a system column
+DETAIL: Constraint "cc" of relation "t" references a system column such as tableoid.
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i * 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i * 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i * 2), i int);
ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
ALTER TABLE t ADD CHECK (g > 0);
@@ -1070,24 +1083,17 @@ ALTER TABLE t MERGE PARTITIONS (tp_1, tp_2) INTO tp_12;
INSERT INTO t VALUES (16);
-- ERROR
INSERT INTO t VALUES (0);
-ERROR: new row for relation "tp_12" violates check constraint "t_i_check"
+ERROR: new row for relation "tp_12" violates check constraint "t_g_check"
DETAIL: Failing row contains (0, virtual).
-- Should be 3 rows: (5), (15), (16):
-SELECT i FROM t ORDER BY i;
- i
-----
- 5
- 15
- 16
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+----
+ 5 | 10
+ 15 | 30
+ 16 | 32
(3 rows)
--- Should be 1 because for the same tableoid (15 + tableoid) = (5 + tableoid) + 10:
-SELECT count(*) FROM t WHERE i = 15 AND g IN (SELECT g + 10 FROM t WHERE i = 5);
- count
--------
- 1
-(1 row)
-
DROP TABLE t;
-- A merged partition needs its own TOAST table; otherwise an out-of-line
-- varlena value carried over from one of the merging partitions has
@@ -1167,6 +1173,45 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+-----
+ 3 | 6
+ 5 | 500
+ 12 | 24
+(3 rows)
+
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_10" has a generation expression different from table "t".
DROP TABLE t;
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 788ca5a28db..ee6fdb44b5e 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1547,7 +1547,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -1576,7 +1576,7 @@ CREATE TRIGGER tp_x_before_insert_row_trigger BEFORE INSERT ON tp_x FOR EACH ROW
i | integer | | not null | | plain | | tp_x.i
t | text | | | 'default_tp_x'::text | main | |
b | bigint | | not null | | plain | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | |
Partition of: t FOR VALUES FROM (0) TO (2)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 2))
Check constraints:
@@ -1627,32 +1627,60 @@ SELECT tableoid::regclass, * FROM t ORDER BY tableoid::regclass::text COLLATE "C
DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so SPLIT PARTITION is rejected -- for a stored column, whose value
+-- cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partitions.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "g" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partitions' OIDs, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_1');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a check constraint depends on a system column
+DETAIL: Constraint "cc" of relation "t" references a system column such as tableoid.
+DROP TABLE t;
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_20" has a generation expression different from table "t".
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
-- that out-of-line varlena attributes coming from the source partition
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 9c41b252ad3..562fcb3401b 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -649,7 +649,7 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
@@ -657,7 +657,7 @@ CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
@@ -736,33 +736,49 @@ DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so MERGE PARTITIONS is rejected. This holds for a stored column,
+-- whose value cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partition (here that
+-- would also break the NOT NULL constraint if the new partition happened to
+-- get the OID mentioned in the expression). A generated column over user
+-- columns only is fine: its value is preserved, as exercised above.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partition's OID, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i * 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i * 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i * 2), i int);
ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
ALTER TABLE t ADD CHECK (g > 0);
@@ -775,9 +791,7 @@ INSERT INTO t VALUES (16);
-- ERROR
INSERT INTO t VALUES (0);
-- Should be 3 rows: (5), (15), (16):
-SELECT i FROM t ORDER BY i;
--- Should be 1 because for the same tableoid (15 + tableoid) = (5 + tableoid) + 10:
-SELECT count(*) FROM t WHERE i = 15 AND g IN (SELECT g + 10 FROM t WHERE i = 5);
+SELECT i, g FROM t ORDER BY i;
DROP TABLE t;
@@ -839,6 +853,40 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+
+
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+DROP TABLE t;
+
+
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index c470c42be71..db383c1ff30 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1122,7 +1122,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
@@ -1162,26 +1162,57 @@ DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so SPLIT PARTITION is rejected -- for a stored column, whose value
+-- cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
+
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partitions.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partitions' OIDs, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_1');
+INSERT INTO t VALUES (0), (1);
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
--
2.55.0
[application/octet-stream] v5-0004-Reject-MERGE-SPLIT-of-partitions-with-row-level-s.patch (15.3K, ../../CAPpHfdv=7MpwkS-n_ECzdM0C9WmpNjNG68mr3gyhAu2qADp9Yg@mail.gmail.com/4-v5-0004-Reject-MERGE-SPLIT-of-partitions-with-row-level-s.patch)
download | inline diff:
From 07692eae02dbb0013f102ae23b29f8e7bfe18047 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Fri, 14 Aug 2026 13:57:26 +0300
Subject: [PATCH v5 4/4] Reject MERGE/SPLIT of partitions with row-level
security
The new partitions created by ALTER TABLE ... MERGE/SPLIT PARTITION are built
from the partitioned table as a template, and row-level security is not part of
that template: policies are not inherited by partitions, and CREATE TABLE ...
LIKE does not copy them either. A source partition that has row security
enabled -- or that has row security enabled with no policy at all, which denies
access outright -- was therefore replaced by a partition that restricts nothing,
silently exposing rows that were hidden until then to anyone able to query the
partition directly.
Unlike the loss of a privilege grant, which only takes access away and is
noticed immediately, this fails in the unsafe direction and is easy to miss long
after the fact. So refuse the operation instead, and let the user re-establish
row security on the new partitions explicitly. Policies defined while row
security is disabled hide nothing today, but they are user-written definitions
that would likewise disappear without a trace, so those are refused as well.
Only the source partitions are examined. Row security on the partitioned table
keeps applying to queries against it, and a partition that never had row
security of its own loses nothing, so neither case is restricted.
Document this behavior and add regression coverage, including the cases that
must keep working: row security on the partitioned table alone, and partitions
without row security of their own.
Reported-by: Melanie Plageman <melanieplageman@gmail.com>
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 18 ++++++
src/backend/commands/tablecmds.c | 61 +++++++++++++++++++
src/test/regress/expected/partition_merge.out | 25 ++++++++
src/test/regress/expected/partition_split.out | 27 ++++++++
src/test/regress/sql/partition_merge.sql | 21 +++++++
src/test/regress/sql/partition_split.sql | 22 +++++++
6 files changed, 174 insertions(+)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0acaa23083b..009230eefae 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1314,6 +1314,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
being merged.
</para>
+ <para>
+ Row-level security is likewise not carried over: the new partition is
+ built from the partitioned table, which does not pass its policies down to
+ its partitions. A partition that has row-level security enabled, or that
+ has policies of its own, is therefore rejected, since the new partition
+ would otherwise expose rows that the merged partitions currently hide.
+ Disable row-level security and drop the policies before merging, and
+ re-establish them on the new partition afterwards.
+ </para>
<para>
Moving rows into the new partition does not emit logical replication
@@ -1465,6 +1474,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
being split.
</para>
+ <para>
+ Row-level security is likewise not carried over: the new partitions are
+ built from the partitioned table, which does not pass its policies down to
+ its partitions. A partition that has row-level security enabled, or that
+ has policies of its own, is therefore rejected, since the new partitions
+ would otherwise expose rows that the partition being split currently
+ hides. Disable row-level security and drop the policies before splitting,
+ and re-establish them on the new partitions afterwards.
+ </para>
<para>
Moving rows into the new partitions does not emit logical replication
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 24ffe257b58..1840738f3f4 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -66,6 +66,7 @@
#include "commands/repack.h"
#include "commands/sequence.h"
#include "commands/tablecmds.h"
+#include "commands/policy.h"
#include "commands/tablespace.h"
#include "commands/trigger.h"
#include "commands/typecmds.h"
@@ -23448,6 +23449,54 @@ createPartitionTable(RangeVar *newPartName,
return newRel;
}
+/*
+ * checkPartitionRowSecurity: refuse MERGE/SPLIT when a source partition has
+ * row-level security of its own.
+ *
+ * The new partitions are built from the partitioned-table template, and row
+ * security is not part of that template: it is neither inherited from the
+ * partitioned table nor copied from the source partitions (CREATE TABLE ...
+ * LIKE does not copy policies either). A partition that restricts, or with
+ * row security enabled and no policy outright denies, direct access to its rows
+ * would therefore be replaced by one that does not, silently exposing rows that
+ * were hidden until now. Unlike the loss of a privilege grant, which merely
+ * takes access away, this fails in the unsafe direction and is easy to miss, so
+ * refuse the operation instead and let the user re-establish row security on
+ * the new partitions explicitly. Policies defined while row security is
+ * disabled hide nothing today, but they are user-written definitions that would
+ * likewise disappear without a trace, so those are refused as well.
+ *
+ * Only the source partitions are examined. Row security on the partitioned
+ * table keeps applying to queries against it, and a partition that never had
+ * row security of its own does not lose any.
+ */
+static void
+checkPartitionRowSecurity(List *sourceOids)
+{
+ foreach_oid(srcOid, sourceOids)
+ {
+ Relation src = table_open(srcOid, NoLock);
+
+ if (src->rd_rel->relrowsecurity || src->rd_rel->relforcerowsecurity)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that has row-level security enabled",
+ RelationGetRelationName(src)),
+ errdetail("Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides."),
+ errhint("Disable row-level security on the partition before the operation, and re-establish it on the new partition afterwards."));
+
+ if (relation_has_policies(src))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that has row-level security policies",
+ RelationGetRelationName(src)),
+ errdetail("The policies are not carried over to the new partition and would be silently lost."),
+ errhint("Drop the policies from the partition before the operation, and define them on the new partition afterwards."));
+
+ table_close(src, NoLock);
+ }
+}
+
/*
* checkPartitionSchemaPublications: refuse MERGE/SPLIT when the new partition(s)
* would land in a schema whose FOR TABLES IN SCHEMA publications differ from
@@ -24019,6 +24068,12 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
table_close(mergingPartition, NoLock);
}
+ /*
+ * Row security of the merged partitions is not carried over to the new
+ * partition; reject rather than silently dropping it.
+ */
+ checkPartitionRowSecurity(mergingPartitions);
+
/* Look up the existing relation by the new partition name. */
RangeVarGetAndCheckCreationNamespace(cmd->name, NoLock, &existingRelid);
@@ -24451,6 +24506,12 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
splitRelOid = RelationGetRelid(splitRel);
+ /*
+ * Row security of the split partition is not carried over to the new
+ * partitions; reject rather than silently dropping it.
+ */
+ checkPartitionRowSecurity(list_make1_oid(splitRelOid));
+
/*
* The new partitions inherit the partitioned table's generation
* expressions, but rows are moved as-is; reject a split partition whose
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 10844fd9f9b..7e1aac3b44d 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1275,6 +1275,31 @@ HINT: Create the new partition in the same schema, or publish the partitioned t
ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
DROP PUBLICATION pub_merge;
DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition with row-level security of its own: it
+-- is not carried over, so the new partition would expose rows the merged
+-- partitions hide. Policies defined while row security is disabled are
+-- rejected too, as they would be lost without a trace.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_1 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partition "tp_0_1" that has row-level security enabled
+DETAIL: Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides.
+HINT: Disable row-level security on the partition before the operation, and re-establish it on the new partition afterwards.
+ALTER TABLE tp_0_1 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- still fails
+ERROR: cannot merge or split partition "tp_0_1" that has row-level security policies
+DETAIL: The policies are not carried over to the new partition and would be silently lost.
+HINT: Drop the policies from the partition before the operation, and define them on the new partition afterwards.
+DROP POLICY hide_secret ON tp_0_1;
+-- Row security on the partitioned table alone is fine: the partitions have
+-- none of their own, so nothing is lost.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP TABLE t;
RESET search_path;
--
DROP SCHEMA partitions_merge_schema;
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index ee6fdb44b5e..98575e00119 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1840,6 +1840,33 @@ ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
DROP PUBLICATION pub_split;
DROP TABLE t;
+-- SPLIT PARTITION rejects a partition with row-level security of its own, for
+-- the same reason as MERGE.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partition "tp_0_2" that has row-level security enabled
+DETAIL: Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides.
+HINT: Disable row-level security on the partition before the operation, and re-establish it on the new partition afterwards.
+ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- still fails
+ERROR: cannot merge or split partition "tp_0_2" that has row-level security policies
+DETAIL: The policies are not carried over to the new partition and would be silently lost.
+HINT: Drop the policies from the partition before the operation, and define them on the new partition afterwards.
+DROP POLICY hide_secret ON tp_0_2;
+-- Row security on the partitioned table alone is fine.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP TABLE t;
RESET search_path;
--
DROP SCHEMA partition_split_schema;
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 562fcb3401b..0fcda645147 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -937,6 +937,27 @@ ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
DROP PUBLICATION pub_merge;
DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition with row-level security of its own: it
+-- is not carried over, so the new partition would expose rows the merged
+-- partitions hide. Policies defined while row security is disabled are
+-- rejected too, as they would be lost without a trace.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_1 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ALTER TABLE tp_0_1 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- still fails
+DROP POLICY hide_secret ON tp_0_1;
+-- Row security on the partitioned table alone is fine: the partitions have
+-- none of their own, so nothing is lost.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP TABLE t;
+
+
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index db383c1ff30..e97f13f749c 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1335,6 +1335,28 @@ ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
DROP PUBLICATION pub_split;
DROP TABLE t;
+-- SPLIT PARTITION rejects a partition with row-level security of its own, for
+-- the same reason as MERGE.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- still fails
+DROP POLICY hide_secret ON tp_0_2;
+-- Row security on the partitioned table alone is fine.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP TABLE t;
+
RESET search_path;
--
--
2.55.0
[application/octet-stream] v5-0002-Peserve-replica-identity-and-publications-in-MERG.patch (24.8K, ../../CAPpHfdv=7MpwkS-n_ECzdM0C9WmpNjNG68mr3gyhAu2qADp9Yg@mail.gmail.com/5-v5-0002-Peserve-replica-identity-and-publications-in-MERG.patch)
download | inline diff:
From a771517aba9727a47c20f5af2b160e2a03c4b986 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:14:57 +0200
Subject: [PATCH v5 2/4] Peserve replica identity and publications in
MERGE/SPLIT PARTITION(s)
The new partition(s) created by ALTER TABLE ... MERGE/SPLIT PARTITION are
built from the partitioned-table template, so they would default to
REPLICA IDENTITY DEFAULT and silently drop out of any publication that the
source partitions were directly part of, changing replication behavior
without a warning.
Carry a uniform, simply-representable replica identity (DEFAULT, FULL or
NOTHING) from the source partitions to the new partition(s). Raise an error
if the sources disagree, or use an index-based identity that cannot be
reproduced automatically, and let the user set it explicitly. Also refuse
the operation when any source partition is a direct member of a publication:
the new partition would otherwise leave it, and faithfully reproducing
per-relation column lists and row filters is ambiguous (especially when
several sources are merged). Publications that cover the partitioned root
continue to include the new partition, so those are unaffected.
For the same reason, refuse to create the new partition in a schema whose
FOR TABLES IN SCHEMA publications differ from those of the source partitions:
such a move would silently add the relocated rows to, or remove them from,
such a publication. The check only triggers when a schema publication is
actually involved, so a cross-schema MERGE/SPLIT remains allowed otherwise;
publications FOR ALL TABLES, or covering the partitioned table itself, keep
covering the new partitions and are unaffected.
Also make the error hints name an action that lets the command succeed, rather
than one to perform after an operation that did not happen.
Document this behavior and add a test coverage.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 35 +++++
src/backend/commands/tablecmds.c | 148 ++++++++++++++++++
src/test/regress/expected/partition_merge.out | 62 ++++++++
src/test/regress/expected/partition_split.out | 60 +++++++
src/test/regress/sql/partition_merge.sql | 50 ++++++
src/test/regress/sql/partition_split.sql | 48 ++++++
6 files changed, 403 insertions(+)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index b8246a7ee48..73e1be7dec8 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ The new partition takes its replica identity from the merged partitions
+ when they all use the same simple setting
+ (<literal>DEFAULT</literal>, <literal>FULL</literal> or
+ <literal>NOTHING</literal>). If they use different settings, or use
+ <literal>REPLICA IDENTITY USING INDEX</literal>, the error is issued
+ and the command is aborted. Give the partitions being merged a uniform,
+ non-index replica identity before merging, and set a different replica
+ identity on the resulting partition afterwards if desired. Likewise, if
+ any of the partitions being merged is directly part of a publication, the
+ command is aborted; publish the partitioned table itself instead of the
+ individual partitions, or remove the partition from the publication before
+ merging. For the same reason, the new partition cannot be created in a
+ schema that is not covered by the same publications defined
+ <literal>FOR TABLES IN SCHEMA</literal> as the schema of the partitions
+ being merged.
+ </para>
+
+
<para>
Moving rows into the new partition does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
@@ -1401,6 +1420,22 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ The new partitions take their replica identity from the split partition,
+ unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
+ case the error is issued and the command is aborted. Give the partition
+ being split a non-index replica identity before splitting, and set a
+ different replica identity on the new partitions afterwards if desired.
+ Likewise, if the partition being split is directly part of a publication,
+ the command is rejected; publish the partitioned table itself instead of
+ the individual partitions, or remove the partition from the publication
+ before splitting. For the same reason, the new partitions cannot be
+ created in a schema that is not covered by the same publications defined
+ <literal>FOR TABLES IN SCHEMA</literal> as the schema of the partition
+ being split.
+ </para>
+
+
<para>
Moving rows into the new partitions does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 351415dafc3..f8805a5980f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -49,6 +49,7 @@
#include "catalog/pg_opclass.h"
#include "catalog/pg_policy.h"
#include "catalog/pg_proc.h"
+#include "catalog/pg_publication.h"
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_rewrite.h"
#include "catalog/pg_statistic_ext.h"
@@ -23410,6 +23411,137 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
return newRel;
}
+/*
+ * checkPartitionSchemaPublications: refuse MERGE/SPLIT when the new partition(s)
+ * would land in a schema whose FOR TABLES IN SCHEMA publications differ from
+ * those of the source partition(s).
+ *
+ * The new partitions are created under the name given in the command, which may
+ * name a different schema than the source partitions live in. A publication
+ * defined FOR TABLES IN SCHEMA covers exactly the tables of that schema, so such
+ * a move would silently add the relocated rows to, or remove them from, that
+ * publication. Publications FOR ALL TABLES, or covering the partitioned table
+ * itself, keep covering the new partitions and are therefore not a problem.
+ *
+ * 'sourceOids' lists the source partition OIDs, 'newPartRels' the new partition
+ * Relations.
+ */
+static void
+checkPartitionSchemaPublications(List *sourceOids, List *newPartRels)
+{
+ foreach_oid(srcOid, sourceOids)
+ {
+ Oid srcNsp = get_rel_namespace(srcOid);
+ List *srcPubs = NIL;
+ bool srcPubsFetched = false;
+
+ foreach_ptr(RelationData, newrel, newPartRels)
+ {
+ Oid newNsp = RelationGetNamespace(newrel);
+ List *newPubs;
+
+ /* Same schema: publication membership cannot change. */
+ if (newNsp == srcNsp)
+ continue;
+
+ if (!srcPubsFetched)
+ {
+ srcPubs = GetSchemaPublications(srcNsp);
+ srcPubsFetched = true;
+ }
+ newPubs = GetSchemaPublications(newNsp);
+
+ /* No schema publication involved, so nothing can change. */
+ if (srcPubs == NIL && newPubs == NIL)
+ continue;
+
+ if (list_length(srcPubs) != list_length(newPubs) ||
+ list_difference_oid(srcPubs, newPubs) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move partition \"%s\" to schema \"%s\" with different publications for tables in schema",
+ get_rel_name(srcOid),
+ get_namespace_name(newNsp)),
+ errdetail("Schema \"%s\" and schema \"%s\" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.",
+ get_namespace_name(srcNsp),
+ get_namespace_name(newNsp)),
+ errhint("Create the new partition in the same schema, or publish the partitioned table itself."));
+ }
+ }
+}
+
+/*
+ * transferPartitionReplicaIdentity: propagate the source partitions' replica
+ * identity to the new partition(s) created by MERGE/SPLIT, and refuse the
+ * operation for cases we cannot handle without silently changing replication
+ * behavior.
+ *
+ * The new partitions are built from the partitioned-table template and would
+ * otherwise default to REPLICA IDENTITY DEFAULT and drop out of any publication
+ * that the source partitions were directly part of. To avoid silent surprises:
+ *
+ * - A uniform, simply-representable replica identity (DEFAULT, FULL or
+ * NOTHING) is carried over to every new partition. If the sources disagree,
+ * or use an index-based identity (which cannot be reproduced on the new
+ * partition automatically), we raise an error and ask the user to set it.
+ *
+ * - If any source partition is a direct member of a publication, we refuse the
+ * operation: the new partition would silently leave the publication, and
+ * faithfully reproducing per-relation column lists and row filters is
+ * ambiguous (especially when several sources are merged). Publications that
+ * cover the partitioned root instead continue to include the new partition.
+ *
+ * 'sourceOids' lists the source partition OIDs (still present, not yet dropped);
+ * 'newPartRels' lists the new partition Relations (exclusively locked).
+ */
+static void
+transferPartitionReplicaIdentity(List *sourceOids, List *newPartRels)
+{
+ char ri_type = '\0';
+ bool ri_seen = false;
+
+ foreach_oid(srcOid, sourceOids)
+ {
+ Relation src = table_open(srcOid, NoLock);
+
+ if (GetRelationIncludedPublications(srcOid) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that is directly part of a publication",
+ RelationGetRelationName(src)),
+ errhint("Publish the partitioned table instead, or remove the partition from the publication before the operation and add the new partition to it afterwards."));
+
+ if (!ri_seen)
+ {
+ ri_type = src->rd_rel->relreplident;
+ ri_seen = true;
+ }
+ else if (ri_type != src->rd_rel->relreplident)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("partitions being merged have different replica identity settings"),
+ errhint("Give all partitions being merged the same replica identity before merging."));
+
+ table_close(src, NoLock);
+ }
+
+ /* Nothing to carry over, or the new partitions already match. */
+ if (!ri_seen || ri_type == REPLICA_IDENTITY_DEFAULT)
+ return;
+
+ if (ri_type == REPLICA_IDENTITY_INDEX)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot automatically transfer an index-based replica identity to the new partition"),
+ errhint("Change the replica identity to a non-index one before the operation, then set it on the new partition with ALTER TABLE ... REPLICA IDENTITY USING INDEX."));
+
+ /* Carry FULL / NOTHING over to each new partition. */
+ foreach_ptr(RelationData, newrel, newPartRels)
+ relation_mark_replica_identity(newrel, ri_type, InvalidOid, true);
+
+ CommandCounterIncrement();
+}
+
/*
* MergePartitionsMoveRows: scan partitions to be merged (mergingPartitions)
* of the partitioned table and move rows into the new partition
@@ -23960,6 +24092,14 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
Assert(OidIsValid(ownerId));
newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ /*
+ * Carry the source partitions' replica identity over to the new
+ * partition, and reject cases that would silently change replication
+ * behavior.
+ */
+ transferPartitionReplicaIdentity(mergingPartitions, list_make1(newPartRel));
+ checkPartitionSchemaPublications(mergingPartitions, list_make1(newPartRel));
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
@@ -24402,6 +24542,14 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
newPartRels = lappend(newPartRels, newPartRel);
}
+ /*
+ * Carry the split partition's replica identity over to the new
+ * partitions, and reject cases that would silently change replication
+ * behavior.
+ */
+ transferPartitionReplicaIdentity(list_make1_oid(splitRelOid), newPartRels);
+ checkPartitionSchemaPublications(list_make1_oid(splitRelOid), newPartRels);
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index ccda2b5843b..0c19e5fa93f 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1167,6 +1167,68 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2'
+ AND relnamespace = 'partitions_merge_schema'::regnamespace;
+ relreplident
+--------------
+ f
+(1 row)
+
+DROP TABLE t;
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: partitions being merged have different replica identity settings
+HINT: Give all partitions being merged the same replica identity before merging.
+DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partition "tp_0_1" that is directly part of a publication
+HINT: Publish the partitioned table instead, or remove the partition from the publication before the operation and add the new partition to it afterwards.
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+-- Creating the new partition in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover it.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema merge is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO partitions_merge_schema2.tp_0_2;
+SELECT count(*) FROM t;
+ count
+-------
+ 2
+(1 row)
+
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLES IN SCHEMA partitions_merge_schema;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2)
+ INTO partitions_merge_schema2.tp_0_2; -- fails
+ERROR: cannot move partition "tp_0_1" to schema "partitions_merge_schema2" with different publications for tables in schema
+DETAIL: Schema "partitions_merge_schema" and schema "partitions_merge_schema2" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.
+HINT: Create the new partition in the same schema, or publish the partitioned table itself.
+-- Staying in the covered schema is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP PUBLICATION pub_merge;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 089f89ed6ac..788ca5a28db 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1751,6 +1751,66 @@ SELECT relname, reltablespace FROM pg_class
tp_lo | 0
(2 rows)
+DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2')
+ AND relnamespace = 'partition_split_schema'::regnamespace ORDER BY relname;
+ relname | relreplident
+---------+--------------
+ tp_0_1 | f
+ tp_1_2 | f
+(2 rows)
+
+DROP TABLE t;
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partition "tp_0_2" that is directly part of a publication
+HINT: Publish the partitioned table instead, or remove the partition from the publication before the operation and add the new partition to it afterwards.
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+-- Creating the new partitions in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover them.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema split is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT count(*) FROM t;
+ count
+-------
+ 2
+(1 row)
+
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLES IN SCHEMA partition_split_schema;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot move partition "tp_0_2" to schema "partition_split_schema2" with different publications for tables in schema
+DETAIL: Schema "partition_split_schema" and schema "partition_split_schema2" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.
+HINT: Create the new partition in the same schema, or publish the partitioned table itself.
+-- Staying in the covered schema is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP PUBLICATION pub_split;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 80dc365b0ce..9c41b252ad3 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -839,6 +839,56 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2'
+ AND relnamespace = 'partitions_merge_schema'::regnamespace;
+DROP TABLE t;
+
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
+
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
+-- Creating the new partition in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover it.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema merge is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO partitions_merge_schema2.tp_0_2;
+SELECT count(*) FROM t;
+DROP TABLE t;
+
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLES IN SCHEMA partitions_merge_schema;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2)
+ INTO partitions_merge_schema2.tp_0_2; -- fails
+-- Staying in the covered schema is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index ffd15e7f969..c470c42be71 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1256,6 +1256,54 @@ SELECT relname, reltablespace FROM pg_class
WHERE relname IN ('tp_lo', 'tp_hi') ORDER BY relname;
DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2')
+ AND relnamespace = 'partition_split_schema'::regnamespace ORDER BY relname;
+DROP TABLE t;
+
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
+-- Creating the new partitions in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover them.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema split is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT count(*) FROM t;
+DROP TABLE t;
+
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLES IN SCHEMA partition_split_schema;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+-- Staying in the covered schema is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
RESET search_path;
--
--
2.55.0
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-20 07:41 jian he <jian.universality@gmail.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
0 siblings, 1 reply; 40+ messages in thread
From: jian he @ 2026-08-20 07:41 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: Melanie Plageman <melanieplageman@gmail.com>; Daniel Gustafsson <daniel@yesql.se>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>
On Wed, Aug 19, 2026 at 7:58 PM Alexander Korotkov <aekorotkov@gmail.com> wrote:
>
> Agree on your corrections expect for deleteSplitPartitionContext(): it
> still have resources to free. The revised patchset is attached.
>
Hi.
-- SPLIT PARTITION rejects a partition with row-level security of its own, for
-- the same reason as MERGE.
CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
ERROR: cannot merge or split partition "tp_0_2" that has row-level
security enabled
DETAIL: Row-level security is not carried over to the new partition,
which would expose rows that the partition currently hides.
HINT: Disable row-level security on the partition before the
operation, and re-establish it on the new partition afterwards.
ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- still fails
ERROR: cannot merge or split partition "tp_0_2" that has row-level
security policies
DETAIL: The policies are not carried over to the new partition and
would be silently lost.
HINT: Drop the policies from the partition before the operation, and
define them on the new partition afterwards.
DROP POLICY hide_secret ON tp_0_2;
----------------------------------
Since we have the MERGE SQL command, it would be better to replace
"the same reason as MERGE."
with "the same reason as MERGE PARTITIONS".
I think the HINT in the first error message is not very helpful, it
suggests disabling row-level security on table tp_0_2.
However, even if with RLS disabled on table tp_0_2, we still need to
drop the policies and redefine them.I am OK with the second HINT.
maybe we can change errhint("Disable row-level security on the
partition before the operation, and re-establish it on the new
partition afterwards."));to errhint("Disable row-level security on the
partition and drop the existing policies before the operation, then
re-establish them on the new partition afterwards."));
"because the row-movement path cannot safely recompute the value while
re-verifying all of the table's constraints against it."
I am not sure the word "path" is necessary.
Other than that, v5 looks good to me. (i didn't review 0001 and 0002).
--------------------
CREATE ACCESS METHOD partitions_merge_heap TYPE TABLE HANDLER
heap_tableam_handler;
begin;
DROP TABLE if exists t;
CREATE TABLE t (i int) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
set local default_table_access_method to partitions_merge_heap;
ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
SELECT a.amname FROM pg_class c, pg_am a WHERE c.relname = 'tp_0_2'
AND a.oid = c.relam;
rollback;
The last SELECT query should return "partitions_merge_heap", IIMHO.
The attached patch based on v5, fixes this issue.
--
jian
https://www.enterprisedb.com/
Attachments:
[text/x-patch] v6-0001-Fix-access-method-for-new-partition-tables.patch (5.5K, ../../CACJufxG0Kqu2Qnei_xZ+DsQVohX95eO9FRNB9ncKOmOPFsFF-A@mail.gmail.com/2-v6-0001-Fix-access-method-for-new-partition-tables.patch)
download | inline diff:
From 40b78f838af24970bb51674de1783a1ebb9cbd17 Mon Sep 17 00:00:00 2001
From: jian he <jian.universality@gmail.com>
Date: Thu, 20 Aug 2026 15:15:00 +0800
Subject: [PATCH v6 1/1] Fix access method for new partition tables
If the partitioned table has a valid table access method, newly created
partitions for MERGE/SPLIT PARTITIONS use the parent table's access method.
Otherwise, fall back to default_table_access_method.
---
src/backend/commands/tablecmds.c | 5 ++++-
src/test/regress/expected/partition_merge.out | 15 +++++++++++++++
src/test/regress/expected/partition_split.out | 18 ++++++++++++++++++
src/test/regress/sql/partition_merge.sql | 11 +++++++++++
src/test/regress/sql/partition_split.sql | 12 ++++++++++++
5 files changed, 60 insertions(+), 1 deletion(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 1840738f3f4..4371694ed8d 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23334,7 +23334,10 @@ createPartitionTable(RangeVar *newPartName,
descriptor = BuildDescForRelation(colList);
/* Look up the access method for the new relation. */
- relamId = (parent_relform->relam != InvalidOid) ? parent_relform->relam : HEAP_TABLE_AM_OID;
+ if (OidIsValid(parent_relform->relam))
+ relamId = parent_relform->relam;
+ else
+ relamId = get_table_am_oid(default_table_access_method, false);
/* Look up the namespace in which we are supposed to create the relation. */
namespaceId =
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 7e1aac3b44d..d16a898e6c0 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -791,6 +791,21 @@ ORDER BY c.relname COLLATE "C";
tp_0_2 | partitions_merge_heap
(2 rows)
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+BEGIN;
+SET LOCAL default_table_access_method to partitions_merge_heap;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT c.relname, a.amname FROM pg_class c, pg_am a
+WHERE c.relname = 'tp_0_2' AND a.oid = c.relam;
+ relname | amname
+---------+-----------------------
+ tp_0_2 | partitions_merge_heap
+(1 row)
+
+COMMIT;
DROP TABLE t;
DROP ACCESS METHOD partitions_merge_heap;
-- Test permission checks. The user needs to own the parent table and all
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 98575e00119..8780d19f90a 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1355,6 +1355,24 @@ ORDER BY c.relname COLLATE "C";
tp_1_2 | partition_split_heap
(3 rows)
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+BEGIN;
+SET LOCAL default_table_access_method to partition_split_heap;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT c.relname, a.amname FROM pg_class c, pg_am a
+WHERE c.relname IN ('tp_0_1', 'tp_1_2')
+AND a.oid = c.relam;
+ relname | amname
+---------+----------------------
+ tp_0_1 | partition_split_heap
+ tp_1_2 | partition_split_heap
+(2 rows)
+
+COMMIT;
DROP TABLE t;
DROP ACCESS METHOD partition_split_heap;
-- Split partition of a temporary table when one of the partitions after
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 0fcda645147..11550a00be8 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -550,6 +550,17 @@ FROM pg_class c JOIN pg_am a ON c.relam = a.oid
WHERE c.oid IN ('t'::regclass, 'tp_0_2'::regclass)
ORDER BY c.relname COLLATE "C";
DROP TABLE t;
+
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+BEGIN;
+SET LOCAL default_table_access_method to partitions_merge_heap;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT c.relname, a.amname FROM pg_class c, pg_am a
+WHERE c.relname = 'tp_0_2' AND a.oid = c.relam;
+COMMIT;
+DROP TABLE t;
DROP ACCESS METHOD partitions_merge_heap;
-- Test permission checks. The user needs to own the parent table and all
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index e97f13f749c..b734d69ba68 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -970,6 +970,18 @@ FROM pg_class c JOIN pg_am a ON c.relam = a.oid
WHERE c.oid IN ('t'::regclass, 'tp_0_1'::regclass, 'tp_1_2'::regclass)
ORDER BY c.relname COLLATE "C";
DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+BEGIN;
+SET LOCAL default_table_access_method to partition_split_heap;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT c.relname, a.amname FROM pg_class c, pg_am a
+WHERE c.relname IN ('tp_0_1', 'tp_1_2')
+AND a.oid = c.relam;
+COMMIT;
+DROP TABLE t;
DROP ACCESS METHOD partition_split_heap;
-- Split partition of a temporary table when one of the partitions after
--
2.34.1
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-20 11:46 Alexander Korotkov <aekorotkov@gmail.com>
parent: jian he <jian.universality@gmail.com>
0 siblings, 2 replies; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-20 11:46 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: Melanie Plageman <melanieplageman@gmail.com>; Daniel Gustafsson <daniel@yesql.se>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>
On Thu, Aug 20, 2026 at 10:42 AM jian he <jian.universality@gmail.com> wrote:
> On Wed, Aug 19, 2026 at 7:58 PM Alexander Korotkov <aekorotkov@gmail.com> wrote:
> >
> > Agree on your corrections expect for deleteSplitPartitionContext(): it
> > still have resources to free. The revised patchset is attached.
> >
>
> Hi.
>
> -- SPLIT PARTITION rejects a partition with row-level security of its own, for
> -- the same reason as MERGE.
> CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
> CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
> ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
> CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
> ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
> (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
> PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
> ERROR: cannot merge or split partition "tp_0_2" that has row-level
> security enabled
> DETAIL: Row-level security is not carried over to the new partition,
> which would expose rows that the partition currently hides.
> HINT: Disable row-level security on the partition before the
> operation, and re-establish it on the new partition afterwards.
> ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
> ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
> (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
> PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- still fails
> ERROR: cannot merge or split partition "tp_0_2" that has row-level
> security policies
> DETAIL: The policies are not carried over to the new partition and
> would be silently lost.
> HINT: Drop the policies from the partition before the operation, and
> define them on the new partition afterwards.
> DROP POLICY hide_secret ON tp_0_2;
> ----------------------------------
> Since we have the MERGE SQL command, it would be better to replace
> "the same reason as MERGE."
> with "the same reason as MERGE PARTITIONS".
>
> I think the HINT in the first error message is not very helpful, it
> suggests disabling row-level security on table tp_0_2.
> However, even if with RLS disabled on table tp_0_2, we still need to
> drop the policies and redefine them.I am OK with the second HINT.
> maybe we can change errhint("Disable row-level security on the
> partition before the operation, and re-establish it on the new
> partition afterwards."));to errhint("Disable row-level security on the
> partition and drop the existing policies before the operation, then
> re-establish them on the new partition afterwards."));
Changed as you proposed.
> "because the row-movement path cannot safely recompute the value while
> re-verifying all of the table's constraints against it."
> I am not sure the word "path" is necessary.
>
> Other than that, v5 looks good to me. (i didn't review 0001 and 0002).
> --------------------
> CREATE ACCESS METHOD partitions_merge_heap TYPE TABLE HANDLER
> heap_tableam_handler;
> begin;
> DROP TABLE if exists t;
> CREATE TABLE t (i int) PARTITION BY RANGE (i);
> CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
> CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
> set local default_table_access_method to partitions_merge_heap;
> ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
> SELECT a.amname FROM pg_class c, pg_am a WHERE c.relname = 'tp_0_2'
> AND a.oid = c.relam;
> rollback;
>
> The last SELECT query should return "partitions_merge_heap", IIMHO.
> The attached patch based on v5, fixes this issue.
Added as 0005 patch to the patchset.
------
Regards,
Alexander Korotkov
Supabase
Attachments:
[application/octet-stream] v6-0004-Reject-MERGE-SPLIT-of-partitions-with-row-level-s.patch (15.4K, ../../CAPpHfdtUDfUk3zu-tdOnBQp2iUp2b4JHkoZb_V3EMbSODuA1Ew@mail.gmail.com/2-v6-0004-Reject-MERGE-SPLIT-of-partitions-with-row-level-s.patch)
download | inline diff:
From 0fe9c6e8b8abe0585cdeac5404288b7ca15f0c15 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Fri, 14 Aug 2026 13:57:26 +0300
Subject: [PATCH v6 4/5] Reject MERGE/SPLIT of partitions with row-level
security
The new partitions created by ALTER TABLE ... MERGE/SPLIT PARTITION are built
from the partitioned table as a template, and row-level security is not part of
that template: policies are not inherited by partitions, and CREATE TABLE ...
LIKE does not copy them either. A source partition that has row security
enabled -- or that has row security enabled with no policy at all, which denies
access outright -- was therefore replaced by a partition that restricts nothing,
silently exposing rows that were hidden until then to anyone able to query the
partition directly.
Unlike the loss of a privilege grant, which only takes access away and is
noticed immediately, this fails in the unsafe direction and is easy to miss long
after the fact. So refuse the operation instead, and let the user re-establish
row security on the new partitions explicitly. Policies defined while row
security is disabled hide nothing today, but they are user-written definitions
that would likewise disappear without a trace, so those are refused as well.
Only the source partitions are examined. Row security on the partitioned table
keeps applying to queries against it, and a partition that never had row
security of its own loses nothing, so neither case is restricted.
Document this behavior and add regression coverage, including the cases that
must keep working: row security on the partitioned table alone, and partitions
without row security of their own.
Reported-by: Melanie Plageman <melanieplageman@gmail.com>
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 18 ++++++
src/backend/commands/tablecmds.c | 61 +++++++++++++++++++
src/test/regress/expected/partition_merge.out | 25 ++++++++
src/test/regress/expected/partition_split.out | 27 ++++++++
src/test/regress/sql/partition_merge.sql | 21 +++++++
src/test/regress/sql/partition_split.sql | 22 +++++++
6 files changed, 174 insertions(+)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index b31f894b19f..3ad8ff0437a 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1314,6 +1314,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
being merged.
</para>
+ <para>
+ Row-level security is likewise not carried over: the new partition is
+ built from the partitioned table, which does not pass its policies down to
+ its partitions. A partition that has row-level security enabled, or that
+ has policies of its own, is therefore rejected, since the new partition
+ would otherwise expose rows that the merged partitions currently hide.
+ Disable row-level security and drop the policies before merging, and
+ re-establish them on the new partition afterwards.
+ </para>
<para>
Moving rows into the new partition does not emit logical replication
@@ -1465,6 +1474,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
being split.
</para>
+ <para>
+ Row-level security is likewise not carried over: the new partitions are
+ built from the partitioned table, which does not pass its policies down to
+ its partitions. A partition that has row-level security enabled, or that
+ has policies of its own, is therefore rejected, since the new partitions
+ would otherwise expose rows that the partition being split currently
+ hides. Disable row-level security and drop the policies before splitting,
+ and re-establish them on the new partitions afterwards.
+ </para>
<para>
Moving rows into the new partitions does not emit logical replication
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 24ffe257b58..b9dd714bedd 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -66,6 +66,7 @@
#include "commands/repack.h"
#include "commands/sequence.h"
#include "commands/tablecmds.h"
+#include "commands/policy.h"
#include "commands/tablespace.h"
#include "commands/trigger.h"
#include "commands/typecmds.h"
@@ -23448,6 +23449,54 @@ createPartitionTable(RangeVar *newPartName,
return newRel;
}
+/*
+ * checkPartitionRowSecurity: refuse MERGE/SPLIT when a source partition has
+ * row-level security of its own.
+ *
+ * The new partitions are built from the partitioned-table template, and row
+ * security is not part of that template: it is neither inherited from the
+ * partitioned table nor copied from the source partitions (CREATE TABLE ...
+ * LIKE does not copy policies either). A partition that restricts, or with
+ * row security enabled and no policy outright denies, direct access to its rows
+ * would therefore be replaced by one that does not, silently exposing rows that
+ * were hidden until now. Unlike the loss of a privilege grant, which merely
+ * takes access away, this fails in the unsafe direction and is easy to miss, so
+ * refuse the operation instead and let the user re-establish row security on
+ * the new partitions explicitly. Policies defined while row security is
+ * disabled hide nothing today, but they are user-written definitions that would
+ * likewise disappear without a trace, so those are refused as well.
+ *
+ * Only the source partitions are examined. Row security on the partitioned
+ * table keeps applying to queries against it, and a partition that never had
+ * row security of its own does not lose any.
+ */
+static void
+checkPartitionRowSecurity(List *sourceOids)
+{
+ foreach_oid(srcOid, sourceOids)
+ {
+ Relation src = table_open(srcOid, NoLock);
+
+ if (src->rd_rel->relrowsecurity || src->rd_rel->relforcerowsecurity)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that has row-level security enabled",
+ RelationGetRelationName(src)),
+ errdetail("Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides."),
+ errhint("Disable row-level security on the partition and drop its policies before the operation, then re-establish them on the new partition afterwards."));
+
+ if (relation_has_policies(src))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that has row-level security policies",
+ RelationGetRelationName(src)),
+ errdetail("The policies are not carried over to the new partition and would be silently lost."),
+ errhint("Drop the policies from the partition before the operation, and define them on the new partition afterwards."));
+
+ table_close(src, NoLock);
+ }
+}
+
/*
* checkPartitionSchemaPublications: refuse MERGE/SPLIT when the new partition(s)
* would land in a schema whose FOR TABLES IN SCHEMA publications differ from
@@ -24019,6 +24068,12 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
table_close(mergingPartition, NoLock);
}
+ /*
+ * Row security of the merged partitions is not carried over to the new
+ * partition; reject rather than silently dropping it.
+ */
+ checkPartitionRowSecurity(mergingPartitions);
+
/* Look up the existing relation by the new partition name. */
RangeVarGetAndCheckCreationNamespace(cmd->name, NoLock, &existingRelid);
@@ -24451,6 +24506,12 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
splitRelOid = RelationGetRelid(splitRel);
+ /*
+ * Row security of the split partition is not carried over to the new
+ * partitions; reject rather than silently dropping it.
+ */
+ checkPartitionRowSecurity(list_make1_oid(splitRelOid));
+
/*
* The new partitions inherit the partitioned table's generation
* expressions, but rows are moved as-is; reject a split partition whose
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 10844fd9f9b..02488636519 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1275,6 +1275,31 @@ HINT: Create the new partition in the same schema, or publish the partitioned t
ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
DROP PUBLICATION pub_merge;
DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition with row-level security of its own: it
+-- is not carried over, so the new partition would expose rows the merged
+-- partitions hide. Policies defined while row security is disabled are
+-- rejected too, as they would be lost without a trace.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_1 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partition "tp_0_1" that has row-level security enabled
+DETAIL: Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides.
+HINT: Disable row-level security on the partition and drop its policies before the operation, then re-establish them on the new partition afterwards.
+ALTER TABLE tp_0_1 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- still fails
+ERROR: cannot merge or split partition "tp_0_1" that has row-level security policies
+DETAIL: The policies are not carried over to the new partition and would be silently lost.
+HINT: Drop the policies from the partition before the operation, and define them on the new partition afterwards.
+DROP POLICY hide_secret ON tp_0_1;
+-- Row security on the partitioned table alone is fine: the partitions have
+-- none of their own, so nothing is lost.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP TABLE t;
RESET search_path;
--
DROP SCHEMA partitions_merge_schema;
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index ee6fdb44b5e..e0f2774c38f 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1840,6 +1840,33 @@ ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
DROP PUBLICATION pub_split;
DROP TABLE t;
+-- SPLIT PARTITION rejects a partition with row-level security of its own, for
+-- the same reason as MERGE PARTITIONS.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partition "tp_0_2" that has row-level security enabled
+DETAIL: Row-level security is not carried over to the new partition, which would expose rows that the partition currently hides.
+HINT: Disable row-level security on the partition and drop its policies before the operation, then re-establish them on the new partition afterwards.
+ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- still fails
+ERROR: cannot merge or split partition "tp_0_2" that has row-level security policies
+DETAIL: The policies are not carried over to the new partition and would be silently lost.
+HINT: Drop the policies from the partition before the operation, and define them on the new partition afterwards.
+DROP POLICY hide_secret ON tp_0_2;
+-- Row security on the partitioned table alone is fine.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP TABLE t;
RESET search_path;
--
DROP SCHEMA partition_split_schema;
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 562fcb3401b..0fcda645147 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -937,6 +937,27 @@ ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
DROP PUBLICATION pub_merge;
DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition with row-level security of its own: it
+-- is not carried over, so the new partition would expose rows the merged
+-- partitions hide. Policies defined while row security is disabled are
+-- rejected too, as they would be lost without a trace.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_1 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ALTER TABLE tp_0_1 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- still fails
+DROP POLICY hide_secret ON tp_0_1;
+-- Row security on the partitioned table alone is fine: the partitions have
+-- none of their own, so nothing is lost.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP TABLE t;
+
+
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index db383c1ff30..89c63bac890 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1335,6 +1335,28 @@ ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
DROP PUBLICATION pub_split;
DROP TABLE t;
+-- SPLIT PARTITION rejects a partition with row-level security of its own, for
+-- the same reason as MERGE PARTITIONS.
+CREATE TABLE t (i int, secret bool) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON tp_0_2 FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ALTER TABLE tp_0_2 DISABLE ROW LEVEL SECURITY;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- still fails
+DROP POLICY hide_secret ON tp_0_2;
+-- Row security on the partitioned table alone is fine.
+ALTER TABLE t ENABLE ROW LEVEL SECURITY;
+CREATE POLICY hide_secret ON t FOR SELECT USING (secret IS NOT TRUE);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP TABLE t;
+
RESET search_path;
--
--
2.55.0
[application/octet-stream] v6-0002-Peserve-replica-identity-and-publications-in-MERG.patch (24.8K, ../../CAPpHfdtUDfUk3zu-tdOnBQp2iUp2b4JHkoZb_V3EMbSODuA1Ew@mail.gmail.com/3-v6-0002-Peserve-replica-identity-and-publications-in-MERG.patch)
download | inline diff:
From a771517aba9727a47c20f5af2b160e2a03c4b986 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:14:57 +0200
Subject: [PATCH v6 2/5] Peserve replica identity and publications in
MERGE/SPLIT PARTITION(s)
The new partition(s) created by ALTER TABLE ... MERGE/SPLIT PARTITION are
built from the partitioned-table template, so they would default to
REPLICA IDENTITY DEFAULT and silently drop out of any publication that the
source partitions were directly part of, changing replication behavior
without a warning.
Carry a uniform, simply-representable replica identity (DEFAULT, FULL or
NOTHING) from the source partitions to the new partition(s). Raise an error
if the sources disagree, or use an index-based identity that cannot be
reproduced automatically, and let the user set it explicitly. Also refuse
the operation when any source partition is a direct member of a publication:
the new partition would otherwise leave it, and faithfully reproducing
per-relation column lists and row filters is ambiguous (especially when
several sources are merged). Publications that cover the partitioned root
continue to include the new partition, so those are unaffected.
For the same reason, refuse to create the new partition in a schema whose
FOR TABLES IN SCHEMA publications differ from those of the source partitions:
such a move would silently add the relocated rows to, or remove them from,
such a publication. The check only triggers when a schema publication is
actually involved, so a cross-schema MERGE/SPLIT remains allowed otherwise;
publications FOR ALL TABLES, or covering the partitioned table itself, keep
covering the new partitions and are unaffected.
Also make the error hints name an action that lets the command succeed, rather
than one to perform after an operation that did not happen.
Document this behavior and add a test coverage.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 35 +++++
src/backend/commands/tablecmds.c | 148 ++++++++++++++++++
src/test/regress/expected/partition_merge.out | 62 ++++++++
src/test/regress/expected/partition_split.out | 60 +++++++
src/test/regress/sql/partition_merge.sql | 50 ++++++
src/test/regress/sql/partition_split.sql | 48 ++++++
6 files changed, 403 insertions(+)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index b8246a7ee48..73e1be7dec8 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ The new partition takes its replica identity from the merged partitions
+ when they all use the same simple setting
+ (<literal>DEFAULT</literal>, <literal>FULL</literal> or
+ <literal>NOTHING</literal>). If they use different settings, or use
+ <literal>REPLICA IDENTITY USING INDEX</literal>, the error is issued
+ and the command is aborted. Give the partitions being merged a uniform,
+ non-index replica identity before merging, and set a different replica
+ identity on the resulting partition afterwards if desired. Likewise, if
+ any of the partitions being merged is directly part of a publication, the
+ command is aborted; publish the partitioned table itself instead of the
+ individual partitions, or remove the partition from the publication before
+ merging. For the same reason, the new partition cannot be created in a
+ schema that is not covered by the same publications defined
+ <literal>FOR TABLES IN SCHEMA</literal> as the schema of the partitions
+ being merged.
+ </para>
+
+
<para>
Moving rows into the new partition does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
@@ -1401,6 +1420,22 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ The new partitions take their replica identity from the split partition,
+ unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
+ case the error is issued and the command is aborted. Give the partition
+ being split a non-index replica identity before splitting, and set a
+ different replica identity on the new partitions afterwards if desired.
+ Likewise, if the partition being split is directly part of a publication,
+ the command is rejected; publish the partitioned table itself instead of
+ the individual partitions, or remove the partition from the publication
+ before splitting. For the same reason, the new partitions cannot be
+ created in a schema that is not covered by the same publications defined
+ <literal>FOR TABLES IN SCHEMA</literal> as the schema of the partition
+ being split.
+ </para>
+
+
<para>
Moving rows into the new partitions does not emit logical replication
messages, in the same way that <command>CLUSTER</command> or
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 351415dafc3..f8805a5980f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -49,6 +49,7 @@
#include "catalog/pg_opclass.h"
#include "catalog/pg_policy.h"
#include "catalog/pg_proc.h"
+#include "catalog/pg_publication.h"
#include "catalog/pg_publication_rel.h"
#include "catalog/pg_rewrite.h"
#include "catalog/pg_statistic_ext.h"
@@ -23410,6 +23411,137 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
return newRel;
}
+/*
+ * checkPartitionSchemaPublications: refuse MERGE/SPLIT when the new partition(s)
+ * would land in a schema whose FOR TABLES IN SCHEMA publications differ from
+ * those of the source partition(s).
+ *
+ * The new partitions are created under the name given in the command, which may
+ * name a different schema than the source partitions live in. A publication
+ * defined FOR TABLES IN SCHEMA covers exactly the tables of that schema, so such
+ * a move would silently add the relocated rows to, or remove them from, that
+ * publication. Publications FOR ALL TABLES, or covering the partitioned table
+ * itself, keep covering the new partitions and are therefore not a problem.
+ *
+ * 'sourceOids' lists the source partition OIDs, 'newPartRels' the new partition
+ * Relations.
+ */
+static void
+checkPartitionSchemaPublications(List *sourceOids, List *newPartRels)
+{
+ foreach_oid(srcOid, sourceOids)
+ {
+ Oid srcNsp = get_rel_namespace(srcOid);
+ List *srcPubs = NIL;
+ bool srcPubsFetched = false;
+
+ foreach_ptr(RelationData, newrel, newPartRels)
+ {
+ Oid newNsp = RelationGetNamespace(newrel);
+ List *newPubs;
+
+ /* Same schema: publication membership cannot change. */
+ if (newNsp == srcNsp)
+ continue;
+
+ if (!srcPubsFetched)
+ {
+ srcPubs = GetSchemaPublications(srcNsp);
+ srcPubsFetched = true;
+ }
+ newPubs = GetSchemaPublications(newNsp);
+
+ /* No schema publication involved, so nothing can change. */
+ if (srcPubs == NIL && newPubs == NIL)
+ continue;
+
+ if (list_length(srcPubs) != list_length(newPubs) ||
+ list_difference_oid(srcPubs, newPubs) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot move partition \"%s\" to schema \"%s\" with different publications for tables in schema",
+ get_rel_name(srcOid),
+ get_namespace_name(newNsp)),
+ errdetail("Schema \"%s\" and schema \"%s\" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.",
+ get_namespace_name(srcNsp),
+ get_namespace_name(newNsp)),
+ errhint("Create the new partition in the same schema, or publish the partitioned table itself."));
+ }
+ }
+}
+
+/*
+ * transferPartitionReplicaIdentity: propagate the source partitions' replica
+ * identity to the new partition(s) created by MERGE/SPLIT, and refuse the
+ * operation for cases we cannot handle without silently changing replication
+ * behavior.
+ *
+ * The new partitions are built from the partitioned-table template and would
+ * otherwise default to REPLICA IDENTITY DEFAULT and drop out of any publication
+ * that the source partitions were directly part of. To avoid silent surprises:
+ *
+ * - A uniform, simply-representable replica identity (DEFAULT, FULL or
+ * NOTHING) is carried over to every new partition. If the sources disagree,
+ * or use an index-based identity (which cannot be reproduced on the new
+ * partition automatically), we raise an error and ask the user to set it.
+ *
+ * - If any source partition is a direct member of a publication, we refuse the
+ * operation: the new partition would silently leave the publication, and
+ * faithfully reproducing per-relation column lists and row filters is
+ * ambiguous (especially when several sources are merged). Publications that
+ * cover the partitioned root instead continue to include the new partition.
+ *
+ * 'sourceOids' lists the source partition OIDs (still present, not yet dropped);
+ * 'newPartRels' lists the new partition Relations (exclusively locked).
+ */
+static void
+transferPartitionReplicaIdentity(List *sourceOids, List *newPartRels)
+{
+ char ri_type = '\0';
+ bool ri_seen = false;
+
+ foreach_oid(srcOid, sourceOids)
+ {
+ Relation src = table_open(srcOid, NoLock);
+
+ if (GetRelationIncludedPublications(srcOid) != NIL)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partition \"%s\" that is directly part of a publication",
+ RelationGetRelationName(src)),
+ errhint("Publish the partitioned table instead, or remove the partition from the publication before the operation and add the new partition to it afterwards."));
+
+ if (!ri_seen)
+ {
+ ri_type = src->rd_rel->relreplident;
+ ri_seen = true;
+ }
+ else if (ri_type != src->rd_rel->relreplident)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("partitions being merged have different replica identity settings"),
+ errhint("Give all partitions being merged the same replica identity before merging."));
+
+ table_close(src, NoLock);
+ }
+
+ /* Nothing to carry over, or the new partitions already match. */
+ if (!ri_seen || ri_type == REPLICA_IDENTITY_DEFAULT)
+ return;
+
+ if (ri_type == REPLICA_IDENTITY_INDEX)
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot automatically transfer an index-based replica identity to the new partition"),
+ errhint("Change the replica identity to a non-index one before the operation, then set it on the new partition with ALTER TABLE ... REPLICA IDENTITY USING INDEX."));
+
+ /* Carry FULL / NOTHING over to each new partition. */
+ foreach_ptr(RelationData, newrel, newPartRels)
+ relation_mark_replica_identity(newrel, ri_type, InvalidOid, true);
+
+ CommandCounterIncrement();
+}
+
/*
* MergePartitionsMoveRows: scan partitions to be merged (mergingPartitions)
* of the partitioned table and move rows into the new partition
@@ -23960,6 +24092,14 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
Assert(OidIsValid(ownerId));
newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ /*
+ * Carry the source partitions' replica identity over to the new
+ * partition, and reject cases that would silently change replication
+ * behavior.
+ */
+ transferPartitionReplicaIdentity(mergingPartitions, list_make1(newPartRel));
+ checkPartitionSchemaPublications(mergingPartitions, list_make1(newPartRel));
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
@@ -24402,6 +24542,14 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
newPartRels = lappend(newPartRels, newPartRel);
}
+ /*
+ * Carry the split partition's replica identity over to the new
+ * partitions, and reject cases that would silently change replication
+ * behavior.
+ */
+ transferPartitionReplicaIdentity(list_make1_oid(splitRelOid), newPartRels);
+ checkPartitionSchemaPublications(list_make1_oid(splitRelOid), newPartRels);
+
/*
* Switch to the table owner's userid, so that any index functions are run
* as that user. Also, lockdown security-restricted operations and
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index ccda2b5843b..0c19e5fa93f 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1167,6 +1167,68 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2'
+ AND relnamespace = 'partitions_merge_schema'::regnamespace;
+ relreplident
+--------------
+ f
+(1 row)
+
+DROP TABLE t;
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: partitions being merged have different replica identity settings
+HINT: Give all partitions being merged the same replica identity before merging.
+DROP TABLE t;
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partition "tp_0_1" that is directly part of a publication
+HINT: Publish the partitioned table instead, or remove the partition from the publication before the operation and add the new partition to it afterwards.
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+-- Creating the new partition in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover it.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema merge is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO partitions_merge_schema2.tp_0_2;
+SELECT count(*) FROM t;
+ count
+-------
+ 2
+(1 row)
+
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLES IN SCHEMA partitions_merge_schema;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2)
+ INTO partitions_merge_schema2.tp_0_2; -- fails
+ERROR: cannot move partition "tp_0_1" to schema "partitions_merge_schema2" with different publications for tables in schema
+DETAIL: Schema "partitions_merge_schema" and schema "partitions_merge_schema2" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.
+HINT: Create the new partition in the same schema, or publish the partitioned table itself.
+-- Staying in the covered schema is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP PUBLICATION pub_merge;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 089f89ed6ac..788ca5a28db 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1751,6 +1751,66 @@ SELECT relname, reltablespace FROM pg_class
tp_lo | 0
(2 rows)
+DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2')
+ AND relnamespace = 'partition_split_schema'::regnamespace ORDER BY relname;
+ relname | relreplident
+---------+--------------
+ tp_0_1 | f
+ tp_1_2 | f
+(2 rows)
+
+DROP TABLE t;
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partition "tp_0_2" that is directly part of a publication
+HINT: Publish the partitioned table instead, or remove the partition from the publication before the operation and add the new partition to it afterwards.
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+-- Creating the new partitions in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover them.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema split is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT count(*) FROM t;
+ count
+-------
+ 2
+(1 row)
+
+DROP TABLE t;
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLES IN SCHEMA partition_split_schema;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot move partition "tp_0_2" to schema "partition_split_schema2" with different publications for tables in schema
+DETAIL: Schema "partition_split_schema" and schema "partition_split_schema2" are not covered by the same publications defined FOR TABLES IN SCHEMA, so the new partition would silently join or leave a publication.
+HINT: Create the new partition in the same schema, or publish the partitioned table itself.
+-- Staying in the covered schema is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP PUBLICATION pub_split;
DROP TABLE t;
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 80dc365b0ce..9c41b252ad3 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -839,6 +839,56 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS carries over a uniform replica identity ...
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE tp_1_2 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT relreplident FROM pg_class WHERE relname = 'tp_0_2'
+ AND relnamespace = 'partitions_merge_schema'::regnamespace;
+DROP TABLE t;
+
+-- ... but rejects merging partitions with different replica identities.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE tp_0_1 REPLICA IDENTITY FULL;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
+
+-- MERGE PARTITIONS rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLE tp_0_1;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
+-- Creating the new partition in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover it.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema merge is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO partitions_merge_schema2.tp_0_2;
+SELECT count(*) FROM t;
+DROP TABLE t;
+
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+CREATE PUBLICATION pub_merge FOR TABLES IN SCHEMA partitions_merge_schema;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2)
+ INTO partitions_merge_schema2.tp_0_2; -- fails
+-- Staying in the covered schema is fine.
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+DROP PUBLICATION pub_merge;
+DROP TABLE t;
+
RESET search_path;
--
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index ffd15e7f969..c470c42be71 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1256,6 +1256,54 @@ SELECT relname, reltablespace FROM pg_class
WHERE relname IN ('tp_lo', 'tp_hi') ORDER BY relname;
DROP TABLE t;
+-- SPLIT PARTITION carries the split partition's replica identity to the new
+-- partitions.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE tp_0_2 REPLICA IDENTITY FULL;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT relname, relreplident FROM pg_class
+ WHERE relname IN ('tp_0_1', 'tp_1_2')
+ AND relnamespace = 'partition_split_schema'::regnamespace ORDER BY relname;
+DROP TABLE t;
+
+-- SPLIT PARTITION rejects a partition that is directly part of a publication.
+CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLE tp_0_2;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
+-- Creating the new partitions in another schema is only rejected when that
+-- actually changes which FOR TABLES IN SCHEMA publications cover them.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+-- No such publication, so a cross-schema split is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT count(*) FROM t;
+DROP TABLE t;
+
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+CREATE PUBLICATION pub_split FOR TABLES IN SCHEMA partition_split_schema;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION partition_split_schema2.tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION partition_split_schema2.tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+-- Staying in the covered schema is fine.
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+DROP PUBLICATION pub_split;
+DROP TABLE t;
+
RESET search_path;
--
--
2.55.0
[application/octet-stream] v6-0005-Use-default_table_access_method-for-MERGE-SPLIT-P.patch (6.6K, ../../CAPpHfdtUDfUk3zu-tdOnBQp2iUp2b4JHkoZb_V3EMbSODuA1Ew@mail.gmail.com/4-v6-0005-Use-default_table_access_method-for-MERGE-SPLIT-P.patch)
download | inline diff:
From cc6be115f19f8fea706e82e7effcf77f2852aaca Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Thu, 20 Aug 2026 14:33:33 +0300
Subject: [PATCH v6 5/5] Use default_table_access_method for MERGE/SPLIT
PARTITION
When the partitioned table has no access method of its own, the partitions
created by ALTER TABLE ... MERGE/SPLIT PARTITION were given heap rather than
the access method CREATE TABLE ... PARTITION OF would have picked. Both
should follow default_table_access_method in that case; an access method on
the partitioned table still wins, as before.
Author: jian he <jian.universality@gmail.com>
Reviewed-by: Alexander Korotkov <aekorotkov@gmail.com>
Discussion: https://postgr.es/m/CACJufxG0Kqu2Qnei_xZ%2BDsQVohX95eO9FRNB9ncKOmOPFsFF-A%40mail.gmail.com
---
src/backend/commands/tablecmds.c | 5 ++++-
src/test/regress/expected/partition_merge.out | 19 ++++++++++++++++
src/test/regress/expected/partition_split.out | 22 +++++++++++++++++++
src/test/regress/sql/partition_merge.sql | 15 +++++++++++++
src/test/regress/sql/partition_split.sql | 17 ++++++++++++++
5 files changed, 77 insertions(+), 1 deletion(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index b9dd714bedd..fca524fcf7e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23334,7 +23334,10 @@ createPartitionTable(RangeVar *newPartName,
descriptor = BuildDescForRelation(colList);
/* Look up the access method for the new relation. */
- relamId = (parent_relform->relam != InvalidOid) ? parent_relform->relam : HEAP_TABLE_AM_OID;
+ if (OidIsValid(parent_relform->relam))
+ relamId = parent_relform->relam;
+ else
+ relamId = get_table_am_oid(default_table_access_method, false);
/* Look up the namespace in which we are supposed to create the relation. */
namespaceId =
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 02488636519..bc4963b06c3 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -791,6 +791,25 @@ ORDER BY c.relname COLLATE "C";
tp_0_2 | partitions_merge_heap
(2 rows)
+DROP TABLE t;
+-- With no access method on the partitioned table, the new partition falls
+-- back to default_table_access_method, just as CREATE TABLE ... PARTITION OF
+-- would.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+BEGIN;
+SET LOCAL default_table_access_method = partitions_merge_heap;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT c.relname, a.amname
+FROM pg_class c JOIN pg_am a ON c.relam = a.oid
+WHERE c.oid = 'tp_0_2'::regclass;
+ relname | amname
+---------+-----------------------
+ tp_0_2 | partitions_merge_heap
+(1 row)
+
+COMMIT;
DROP TABLE t;
DROP ACCESS METHOD partitions_merge_heap;
-- Test permission checks. The user needs to own the parent table and all
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index e0f2774c38f..49e63d66aef 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1355,6 +1355,28 @@ ORDER BY c.relname COLLATE "C";
tp_1_2 | partition_split_heap
(3 rows)
+DROP TABLE t;
+-- With no access method on the partitioned table, the new partitions fall
+-- back to default_table_access_method, just as CREATE TABLE ... PARTITION OF
+-- would.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+BEGIN;
+SET LOCAL default_table_access_method = partition_split_heap;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT c.relname, a.amname
+FROM pg_class c JOIN pg_am a ON c.relam = a.oid
+WHERE c.oid IN ('tp_0_1'::regclass, 'tp_1_2'::regclass)
+ORDER BY c.relname COLLATE "C";
+ relname | amname
+---------+----------------------
+ tp_0_1 | partition_split_heap
+ tp_1_2 | partition_split_heap
+(2 rows)
+
+COMMIT;
DROP TABLE t;
DROP ACCESS METHOD partition_split_heap;
-- Split partition of a temporary table when one of the partitions after
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 0fcda645147..558f5a12a86 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -550,6 +550,21 @@ FROM pg_class c JOIN pg_am a ON c.relam = a.oid
WHERE c.oid IN ('t'::regclass, 'tp_0_2'::regclass)
ORDER BY c.relname COLLATE "C";
DROP TABLE t;
+
+-- With no access method on the partitioned table, the new partition falls
+-- back to default_table_access_method, just as CREATE TABLE ... PARTITION OF
+-- would.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+BEGIN;
+SET LOCAL default_table_access_method = partitions_merge_heap;
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+SELECT c.relname, a.amname
+FROM pg_class c JOIN pg_am a ON c.relam = a.oid
+WHERE c.oid = 'tp_0_2'::regclass;
+COMMIT;
+DROP TABLE t;
DROP ACCESS METHOD partitions_merge_heap;
-- Test permission checks. The user needs to own the parent table and all
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index 89c63bac890..e255cff077c 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -970,6 +970,23 @@ FROM pg_class c JOIN pg_am a ON c.relam = a.oid
WHERE c.oid IN ('t'::regclass, 'tp_0_1'::regclass, 'tp_1_2'::regclass)
ORDER BY c.relname COLLATE "C";
DROP TABLE t;
+
+-- With no access method on the partitioned table, the new partitions fall
+-- back to default_table_access_method, just as CREATE TABLE ... PARTITION OF
+-- would.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+BEGIN;
+SET LOCAL default_table_access_method = partition_split_heap;
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+SELECT c.relname, a.amname
+FROM pg_class c JOIN pg_am a ON c.relam = a.oid
+WHERE c.oid IN ('tp_0_1'::regclass, 'tp_1_2'::regclass)
+ORDER BY c.relname COLLATE "C";
+COMMIT;
+DROP TABLE t;
DROP ACCESS METHOD partition_split_heap;
-- Split partition of a temporary table when one of the partitions after
--
2.55.0
[application/octet-stream] v6-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch (11.2K, ../../CAPpHfdtUDfUk3zu-tdOnBQp2iUp2b4JHkoZb_V3EMbSODuA1Ew@mail.gmail.com/5-v6-0001-Don-t-logically-decode-MERGE-SPLIT-PARTITION-row-.patch)
download | inline diff:
From bd263258875d389d246e3e89c97824c6575f388c Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:08:36 +0200
Subject: [PATCH v6 1/5] Don't logically decode MERGE/SPLIT PARTITION row
movement
ALTER TABLE ... MERGE/SPLIT PARTITION relocates rows between partitions of the
same partitioned table by re-inserting them into the freshly created
partition(s), using plain heap inserts. Logical decoding emitted those as
INSERTs into the new partition with no matching DELETEs for the source rows,
which corrupts logical replication subscribers.
Pass TABLE_INSERT_NO_LOGICAL to the movers so the relocation is not decoded,
just as CLUSTER and VACUUM FULL already do for their rewrites. MERGE/SPLIT
PARTITION is a schema change that is not itself replicated, and the moved rows
still exist on subscribers, so suppressing the inserts keeps them consistent.
Document the behavior in the MERGE PARTITIONS and SPLIT PARTITION
commands descriptions, and add a test_decoding regression test.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
contrib/test_decoding/Makefile | 3 +-
.../expected/partition_merge_split.out | 56 +++++++++++++++++++
contrib/test_decoding/meson.build | 1 +
.../sql/partition_merge_split.sql | 34 +++++++++++
doc/src/sgml/ref/alter_table.sgml | 30 ++++++++++
src/backend/commands/tablecmds.c | 20 +++++--
6 files changed, 139 insertions(+), 5 deletions(-)
create mode 100644 contrib/test_decoding/expected/partition_merge_split.out
create mode 100644 contrib/test_decoding/sql/partition_merge_split.sql
diff --git a/contrib/test_decoding/Makefile b/contrib/test_decoding/Makefile
index 0111124399a..ab90cd7fec2 100644
--- a/contrib/test_decoding/Makefile
+++ b/contrib/test_decoding/Makefile
@@ -5,7 +5,8 @@ PGFILEDESC = "test_decoding - example of a logical decoding output plugin"
REGRESS = ddl xact rewrite toast permissions decoding_in_xact \
decoding_into_rel binary prepared replorigin time messages \
- repack spill slot truncate stream stats twophase twophase_stream
+ repack spill slot truncate stream stats twophase twophase_stream \
+ partition_merge_split
ISOLATION = mxact delayed_startup ondisk_startup concurrent_ddl_dml \
oldest_xmin snapshot_transfer subxact_without_top concurrent_stream \
twophase_snapshot slot_creation_error catalog_change_snapshot \
diff --git a/contrib/test_decoding/expected/partition_merge_split.out b/contrib/test_decoding/expected/partition_merge_split.out
new file mode 100644
index 00000000000..63ec5af98d0
--- /dev/null
+++ b/contrib/test_decoding/expected/partition_merge_split.out
@@ -0,0 +1,56 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+ ?column?
+----------
+ init
+(1 row)
+
+INSERT INTO part VALUES (1), (11);
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ count
+-------
+ 4
+(1 row)
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+------
+(0 rows)
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+ data
+--------------------------------------------
+ BEGIN
+ table public.part_1: INSERT: id[integer]:2
+ COMMIT
+(3 rows)
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+ ?column?
+----------
+ stop
+(1 row)
+
+DROP TABLE part;
diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build
index ac655853d26..a504bc00794 100644
--- a/contrib/test_decoding/meson.build
+++ b/contrib/test_decoding/meson.build
@@ -42,6 +42,7 @@ tests += {
'stats',
'twophase',
'twophase_stream',
+ 'partition_merge_split',
],
'regress_args': [
'--temp-config', files('logical.conf'),
diff --git a/contrib/test_decoding/sql/partition_merge_split.sql b/contrib/test_decoding/sql/partition_merge_split.sql
new file mode 100644
index 00000000000..efdd6019ebd
--- /dev/null
+++ b/contrib/test_decoding/sql/partition_merge_split.sql
@@ -0,0 +1,34 @@
+-- Row movement performed by ALTER TABLE ... MERGE/SPLIT PARTITION must not be
+-- logically decoded: the relocation is physical (like CLUSTER/VACUUM FULL) and
+-- the DDL itself is not replicated, so emitting INSERTs for the moved rows
+-- (without matching DELETEs) would corrupt logical subscribers.
+SET synchronous_commit = on;
+
+CREATE TABLE part (id int PRIMARY KEY) PARTITION BY RANGE (id);
+CREATE TABLE part_1 PARTITION OF part FOR VALUES FROM (0) TO (10);
+CREATE TABLE part_2 PARTITION OF part FOR VALUES FROM (10) TO (20);
+
+SELECT 'init' FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding');
+
+INSERT INTO part VALUES (1), (11);
+
+-- Drain the two INSERTs.
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- MERGE: the relocation of the rows must not be decoded, so nothing (no
+-- INSERTs, and with skip-empty-xacts no empty transaction either) is emitted.
+ALTER TABLE part MERGE PARTITIONS (part_1, part_2) INTO part_merged;
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- SPLIT: likewise.
+ALTER TABLE part SPLIT PARTITION part_merged INTO
+ (PARTITION part_1 FOR VALUES FROM (0) TO (10),
+ PARTITION part_2 FOR VALUES FROM (10) TO (20));
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- A normal INSERT is still decoded afterwards.
+INSERT INTO part VALUES (2);
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+SELECT 'stop' FROM pg_drop_replication_slot('regression_slot');
+DROP TABLE part;
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..b8246a7ee48 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Moving rows into the new partition does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... MERGE PARTITIONS</command> is a schema change and
+ is not itself replicated to logical replication subscribers. If changes
+ are published for the partitioned table itself (see
+ <literal>publish_via_partition_root</literal>), subscribers are unaffected
+ and may keep their own partition layout. Otherwise changes are published
+ for the individual partitions, and the new partition is not part of the
+ subscription until the subscription is refreshed; changes made to it in
+ the meantime are not applied, so refreshing without copying its data would
+ silently lose them.
+ </para>
+
<note>
<para>
Merging partitions acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
@@ -1386,6 +1401,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Moving rows into the new partitions does not emit logical replication
+ messages, in the same way that <command>CLUSTER</command> or
+ <command>VACUUM FULL</command> do not. Note that
+ <command>ALTER TABLE ... SPLIT PARTITION</command> is a schema change and
+ is not itself replicated to logical replication subscribers. If changes
+ are published for the partitioned table itself (see
+ <literal>publish_via_partition_root</literal>), subscribers are unaffected
+ and may keep their own partition layout. Otherwise changes are published
+ for the individual partitions, and the new partitions are not part of the
+ subscription until the subscription is refreshed; changes made to them in
+ the meantime are not applied, so refreshing without copying their data
+ would silently lose them.
+ </para>
+
<note>
<para>
Split partition acquires an <literal>ACCESS EXCLUSIVE</literal> lock on
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 766f8985479..351415dafc3 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -23423,8 +23423,16 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
AlteredTableInfo *tab;
ListCell *ltab;
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Also suppress logical
+ * decoding of these inserts: merging partitions physically relocates rows
+ * within the same partitioned table, much like CLUSTER or VACUUM FULL.
+ * The relocation is not a user-level INSERT, and MERGE PARTITIONS is DDL
+ * that logical replication does not replicate anyway; emitting INSERTs
+ * for the moved rows (with no matching DELETEs for the source rows) would
+ * corrupt logical subscribers.
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
@@ -24091,8 +24099,12 @@ static void
SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
List *partlist, List *newPartRels)
{
- /* The FSM is empty, so don't bother using it. */
- uint32 ti_options = TABLE_INSERT_SKIP_FSM;
+ /*
+ * The FSM is empty, so don't bother using it. Suppress logical decoding
+ * of these inserts as well; see the matching comment in
+ * MergePartitionsMoveRows().
+ */
+ uint32 ti_options = TABLE_INSERT_SKIP_FSM | TABLE_INSERT_NO_LOGICAL;
CommandId mycid;
EState *estate;
ListCell *listptr,
--
2.55.0
[application/octet-stream] v6-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch (56.5K, ../../CAPpHfdtUDfUk3zu-tdOnBQp2iUp2b4JHkoZb_V3EMbSODuA1Ew@mail.gmail.com/6-v6-0003-Don-t-recalculate-generated-columns-during-MERGE-.patch)
download | inline diff:
From d92ea562df7549833e31011a7dc9a2f7eb04d65b Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Mon, 3 Aug 2026 00:19:19 +0200
Subject: [PATCH v6 3/5] Don't recalculate generated columns during MERGE/SPLIT
PARTITION(S)
ALTER TABLE ... MERGE/SPLIT PARTITION unconditionally recomputed every stored
generated column of the moved rows using the partitioned table's generation
expression. When a leaf partition's generation expression -- or a function it
calls -- differed from the partitioned table's, this silently rewrote
already-stored values, and could even break constraints.
Relocating a row between partitions never changes a user column, so a stored
generated column defined over user columns yields the same value; move it as-is
instead of recomputing, as every other command preserves generated column
values. This alone removes the silent data changes and constraint violations
reported for such columns.
Moving values as-is is only correct when the source partition's generation
expression matches the partitioned table's. A partition can carry a different
expression (ATTACH PARTITION requires the generated-column kind to match but
does not compare the expressions), in which case the moved-as-is value would not
match the new partition's generation expression -- silently storing data
inconsistent with the schema, and possibly violating NOT NULL, CHECK, or
foreign-key constraints. Reject MERGE/SPLIT in that case, in the new
checkPartitionGenExprMatchesParent().
What does legitimately change on the move is tableoid, the only system column
allowed in such expressions, so the new checkPartitionSystemColumnRefs() rejects
every dependency on it:
- A stored generated column would have to be recomputed, but unlike a normal
insert the row-movement path does not re-verify NOT NULL, foreign-key, or
generated-column-dependent CHECK constraints, so a recomputed value could
silently violate them. A virtual generated column is not stored at all, so
its value would silently change as soon as the rows live in the new
partition, with the same consequences.
- A CHECK constraint would have to be re-verified against the new partition's
OID, and that cannot be done faithfully either: the row movement runs under
RestrictSearchPath(), so a search_path dependent expression such as
tableoid::regclass::text does not evaluate the way it would for a regular
INSERT, which makes the re-verification both unreliable and confusing.
As nothing is recomputed or re-verified anymore, the machinery that did so
during the row move is gone: createTableConstraints() no longer records
generated columns in AlteredTableInfo.newvals nor CHECK constraints in
AlteredTableInfo.constraints, and the two row-move helpers that evaluated them
are removed, along with the work queue entry and arguments that only existed to
carry them. Since nothing creates a work queue entry for the new partitions
anymore, the loops that deleted it again go away too.
Note in both row-move functions that they run under a restricted search path,
so that whatever gets evaluated there in the future is held to that.
Document the behavior and add regression coverage for all three rejections.
Existing MERGE/SPLIT tests that relied on recomputation now assert the rejection
or use a generation expression matching the partitioned table, and a
function-change test shows a plain generated column's value preserved.
Discussion: https://postgr.es/m/CAN4CZFNCU=t09M=+r2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw@mail.gmail.com
---
doc/src/sgml/ref/alter_table.sgml | 30 ++
src/backend/commands/tablecmds.c | 455 +++++++++---------
src/test/regress/expected/partition_merge.out | 127 +++--
src/test/regress/expected/partition_split.out | 74 ++-
src/test/regress/sql/partition_merge.sql | 88 +++-
src/test/regress/sql/partition_split.sql | 55 ++-
6 files changed, 505 insertions(+), 324 deletions(-)
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 73e1be7dec8..b31f894b19f 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -1281,6 +1281,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
dependencies are not silently lost during merge.
</para>
+ <para>
+ Stored generated columns keep their existing values; the merge does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the merge is
+ rejected if a merged partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partition's
+ stored data inconsistent with its own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row movement cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partition takes its replica identity from the merged partitions
when they all use the same simple setting
@@ -1420,6 +1435,21 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
from the source partition's indexes.
</para>
+ <para>
+ Stored generated columns keep their existing values; the split does not
+ recompute them from the partitioned table's generation expression, which
+ is consistent with how other commands preserve generated column values.
+ Because the values are moved rather than recomputed, the split is
+ rejected if the split partition's generation expression differs from the
+ partitioned table's, which would otherwise leave the new partitions'
+ stored data inconsistent with their own generation expression.
+ As a further exception, if a stored generated column's expression
+ references a system column such as <structfield>tableoid</structfield>
+ (whose value would change when a row is moved to another partition), the
+ command is rejected, because the row movement cannot safely recompute
+ the value while re-verifying all of the table's constraints against it.
+ </para>
+
<para>
The new partitions take their replica identity from the split partition,
unless it uses <literal>REPLICA IDENTITY USING INDEX</literal>, in which
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f8805a5980f..24ffe257b58 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -22916,92 +22916,6 @@ GetAttributeStorage(Oid atttypid, const char *storagemode)
return cstorage;
}
-/*
- * buildExpressionExecutionStates: build the needed expression execution states
- * for new partition (newPartRel) checks and initialize expressions for
- * generated columns. All expressions should be created in "tab"
- * (AlteredTableInfo structure).
- */
-static void
-buildExpressionExecutionStates(AlteredTableInfo *tab, Relation newPartRel, EState *estate)
-{
- /*
- * Build the needed expression execution states. Here, we expect only NOT
- * NULL and CHECK constraint.
- */
- foreach_ptr(NewConstraint, con, tab->constraints)
- {
- switch (con->contype)
- {
- case CONSTR_CHECK:
-
- /*
- * We already expanded virtual expression in
- * createTableConstraints.
- */
- con->qualstate = ExecPrepareExpr((Expr *) con->qual, estate);
- break;
- case CONSTR_NOTNULL:
- /* Nothing to do here. */
- break;
- default:
- elog(ERROR, "unrecognized constraint type: %d",
- (int) con->contype);
- }
- }
-
- /* Expression already planned in createTableConstraints */
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- ex->exprstate = ExecInitExpr((Expr *) ex->expr, NULL);
-}
-
-/*
- * evaluateGeneratedExpressionsAndCheckConstraints: evaluate any generated
- * expressions for "tab" (AlteredTableInfo structure) whose inputs come from
- * the new tuple (insertslot) of the new partition (newPartRel).
- */
-static void
-evaluateGeneratedExpressionsAndCheckConstraints(AlteredTableInfo *tab,
- Relation newPartRel,
- TupleTableSlot *insertslot,
- ExprContext *econtext)
-{
- econtext->ecxt_scantuple = insertslot;
-
- foreach_ptr(NewColumnValue, ex, tab->newvals)
- {
- if (!ex->is_generated)
- continue;
-
- insertslot->tts_values[ex->attnum - 1]
- = ExecEvalExpr(ex->exprstate,
- econtext,
- &insertslot->tts_isnull[ex->attnum - 1]);
- }
-
- foreach_ptr(NewConstraint, con, tab->constraints)
- {
- switch (con->contype)
- {
- case CONSTR_CHECK:
- if (!ExecCheck(con->qualstate, econtext))
- ereport(ERROR,
- errcode(ERRCODE_CHECK_VIOLATION),
- errmsg("check constraint \"%s\" of relation \"%s\" is violated by some row",
- con->name, RelationGetRelationName(newPartRel)),
- errtableconstraint(newPartRel, con->name));
- break;
- case CONSTR_NOTNULL:
- case CONSTR_FOREIGN:
- /* Nothing to do here */
- break;
- default:
- elog(ERROR, "unrecognized constraint type: %d",
- (int) con->contype);
- }
- }
-}
-
/*
* getAttributesList: build a list of columns (ColumnDef) based on parent_rel
*/
@@ -23052,15 +22966,171 @@ getAttributesList(Relation parent_rel)
return colList;
}
+/*
+ * expression_references_system_column: walker that returns true if the given
+ * expression references any system column (a Var with a negative attribute
+ * number, such as tableoid). Used to decide whether a stored generated column
+ * must be recomputed when a row is relocated between partitions.
+ */
+static bool
+expression_references_system_column(Node *node, void *context)
+{
+ if (node == NULL)
+ return false;
+ if (IsA(node, Var) && ((Var *) node)->varattno < 0)
+ return true;
+ return expression_tree_walker(node, expression_references_system_column,
+ context);
+}
+
+/*
+ * checkPartitionSystemColumnRefs: reject MERGE/SPLIT PARTITION when the
+ * partitioned table has a generated column or a CHECK constraint whose
+ * expression references a system column.
+ *
+ * Only tableoid may appear in such expressions, and it is precisely the value
+ * that changes when a row is relocated into the new partition. Neither
+ * dependency can be honored during the row movement:
+ *
+ * - A stored generated column would have to be recomputed, but the row-movement
+ * path does not re-verify NOT NULL, foreign-key, or generated-column-dependent
+ * CHECK constraints the way a normal insert does, so a recomputed value could
+ * silently violate them. A virtual generated column is not stored at all, so
+ * its value silently changes as soon as the rows live in the new partition.
+ *
+ * - A CHECK constraint would have to be re-verified against the new partition's
+ * OID. We cannot do that faithfully either: the row movement runs under
+ * RestrictSearchPath(), so a search_path-dependent expression such as
+ * tableoid::regclass::text does not evaluate the way it would for a regular
+ * INSERT, which would make the re-verification both unreliable and confusing.
+ *
+ * So reject these cases and let the user handle such columns and constraints
+ * explicitly. In the future we may implement recomputation together with a
+ * full re-validation of the affected constraints.
+ */
+static void
+checkPartitionSystemColumnRefs(Relation parent_rel)
+{
+ TupleDesc tupleDesc = RelationGetDescr(parent_rel);
+ TupleConstr *constr = tupleDesc->constr;
+
+ if (constr == NULL)
+ return;
+
+ /* Generated columns, both stored and virtual. */
+ if (constr->has_generated_stored || constr->has_generated_virtual)
+ {
+ for (AttrNumber attno = 1; attno <= tupleDesc->natts; attno++)
+ {
+ Form_pg_attribute attr = TupleDescAttr(tupleDesc, attno - 1);
+
+ if (attr->attisdropped || attr->attgenerated == '\0')
+ continue;
+
+ if (expression_references_system_column(build_generation_expression(parent_rel, attno),
+ NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a generated column depends on a system column"),
+ errdetail("Column \"%s\" of relation \"%s\" is generated from an expression that references a system column such as tableoid.",
+ NameStr(attr->attname),
+ RelationGetRelationName(parent_rel)));
+ }
+ }
+
+ /* CHECK constraints. */
+ for (int ccnum = 0; ccnum < constr->num_check; ccnum++)
+ {
+ if (expression_references_system_column(stringToNode(constr->check[ccnum].ccbin),
+ NULL))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a check constraint depends on a system column"),
+ errdetail("Constraint \"%s\" of relation \"%s\" references a system column such as tableoid.",
+ constr->check[ccnum].ccname,
+ RelationGetRelationName(parent_rel)));
+ }
+}
+
+/*
+ * checkPartitionGenExprMatchesParent: reject MERGE/SPLIT PARTITION when a
+ * source partition has a generated column whose generation expression differs
+ * from the partitioned table's.
+ *
+ * MERGE/SPLIT PARTITION relocates rows into the new partition and copies stored
+ * generated columns as-is rather than recomputing them (see
+ * createTableConstraints()). Since the new partition is created from the
+ * partitioned table as a template, moving values as-is is only correct when the
+ * source partition's generation expression matches the partitioned table's.
+ * Otherwise the moved value would not match the new partition's generation
+ * expression, silently storing data inconsistent with the schema and possibly
+ * violating NOT NULL, CHECK, or foreign-key constraints.
+ *
+ * A partition can end up with a generation expression different from the
+ * partitioned table's via ATTACH PARTITION, which requires the generated-column
+ * kind to match but does not compare the expressions themselves (see
+ * MergeAttributesIntoExisting()).
+ */
+static void
+checkPartitionGenExprMatchesParent(Relation parent_rel, Relation partRel)
+{
+ TupleDesc parentDesc = RelationGetDescr(parent_rel);
+ TupleConstr *constr = parentDesc->constr;
+ AttrMap *attmap = NULL;
+
+ /* Nothing to compare if the partitioned table has no generated columns. */
+ if (constr == NULL ||
+ !(constr->has_generated_stored || constr->has_generated_virtual))
+ return;
+
+ for (AttrNumber parent_attno = 1; parent_attno <= parentDesc->natts;
+ parent_attno++)
+ {
+ Form_pg_attribute pattr = TupleDescAttr(parentDesc, parent_attno - 1);
+ AttrNumber child_attno;
+ Node *parentExpr;
+ Node *childExpr;
+ bool found_whole_row;
+
+ if (pattr->attisdropped || pattr->attgenerated == '\0')
+ continue;
+
+ /*
+ * Column names match between a partitioned table and its partitions,
+ * and so does the generated-column kind; only the expression can
+ * differ (all enforced/allowed by MergeAttributesIntoExisting()).
+ */
+ child_attno = get_attnum(RelationGetRelid(partRel), NameStr(pattr->attname));
+ Assert(child_attno != InvalidAttrNumber);
+
+ parentExpr = build_generation_expression(parent_rel, parent_attno);
+ childExpr = build_generation_expression(partRel, child_attno);
+
+ /* Rewrite the partition's expression into the parent's numbering. */
+ if (attmap == NULL)
+ attmap = build_attrmap_by_name(parentDesc,
+ RelationGetDescr(partRel), false);
+ childExpr = map_variable_attnos(childExpr, 1, 0, attmap,
+ InvalidOid, &found_whole_row);
+
+ if (found_whole_row || !equal(parentExpr, childExpr))
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot merge or split partitions when a partition's generation expression differs from the partitioned table"),
+ errdetail("Generated column \"%s\" of partition \"%s\" has a generation expression different from table \"%s\".",
+ NameStr(pattr->attname),
+ RelationGetRelationName(partRel),
+ RelationGetRelationName(parent_rel)));
+ }
+}
+
/*
* createTableConstraints:
- * create check constraints, default values, and generated values for newRel
- * based on parent_rel. tab is pending-work queue for newRel, we may need it in
- * MergePartitionsMoveRows.
+ * create check constraints and column defaults (including generation
+ * expressions) for newRel based on parent_rel.
*/
static void
-createTableConstraints(List **wqueue, AlteredTableInfo *tab,
- Relation parent_rel, Relation newRel)
+createTableConstraints(Relation parent_rel, Relation newRel)
{
TupleDesc tupleDesc;
TupleConstr *constr;
@@ -23102,7 +23172,6 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
bool found_whole_row;
AttrNumber num;
Node *def;
- NewColumnValue *newval;
if (attribute->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
this_default = build_generation_expression(parent_rel, attribute->attnum);
@@ -23124,19 +23193,18 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
StoreAttrDefault(newRel, num, def, false);
/*
- * Stored generated column expressions in parent_rel might
- * reference the tableoid. newRel, parent_rel tableoid clear is
- * not the same. If so, these stored generated columns require
- * recomputation for newRel within MergePartitionsMoveRows.
+ * Relocating a row between partitions never changes a user
+ * column, so a stored generated column defined over user columns
+ * keeps the same value; we move it as-is rather than recomputing
+ * it, which is what every other command does. (A source
+ * partition whose generation expression differs from the
+ * partitioned table's has already been rejected by
+ * checkPartitionGenExprMatchesParent(), and an expression
+ * depending on a system column by
+ * checkPartitionSystemColumnRefs(); moving as-is here also avoids
+ * silently rewriting stored data when a function the expression
+ * calls has since been redefined.)
*/
- if (attribute->attgenerated == ATTRIBUTE_GENERATED_STORED)
- {
- newval = palloc0_object(NewColumnValue);
- newval->attnum = num;
- newval->expr = expression_planner((Expr *) def);
- newval->is_generated = (attribute->attgenerated != '\0');
- tab->newvals = lappend(tab->newvals, newval);
- }
}
}
@@ -23195,40 +23263,13 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
CommandCounterIncrement();
/*
- * parent_rel check constraint expression may reference tableoid, so later
- * in MergePartitionsMoveRows, we need to evaluate the check constraint
- * again for the newRel. We can check whether the check constraint
- * contains a tableoid reference via pull_varattnos.
+ * The relocated rows satisfy the new partition's CHECK constraints
+ * without any re-verification here: the constraints are copied from the
+ * partitioned table, which the source partitions already inherited, and
+ * the row movement changes no column value. Constraints depending on a
+ * system column, the one thing that does change, were rejected by
+ * checkPartitionSystemColumnRefs().
*/
- foreach_ptr(CookedConstraint, ccon, cookedConstraints)
- {
- if (!ccon->skip_validation)
- {
- Node *qual;
- Bitmapset *attnums = NULL;
-
- Assert(ccon->contype == CONSTR_CHECK);
- qual = expand_generated_columns_in_expr(ccon->expr, newRel, 1);
- pull_varattnos(qual, 1, &attnums);
-
- /*
- * Add a check only if it contains a tableoid
- * (TableOidAttributeNumber).
- */
- if (bms_is_member(TableOidAttributeNumber - FirstLowInvalidHeapAttributeNumber,
- attnums))
- {
- NewConstraint *newcon;
-
- newcon = palloc0_object(NewConstraint);
- newcon->name = ccon->name;
- newcon->contype = CONSTR_CHECK;
- newcon->qual = qual;
-
- tab->constraints = lappend(tab->constraints, newcon);
- }
- }
- }
/* Don't need the cookedConstraints anymore. */
list_free_deep(cookedConstraints);
@@ -23266,7 +23307,7 @@ createTableConstraints(List **wqueue, AlteredTableInfo *tab,
* Returns the created relation (locked in AccessExclusiveLock mode).
*/
static Relation
-createPartitionTable(List **wqueue, RangeVar *newPartName,
+createPartitionTable(RangeVar *newPartName,
Relation parent_rel, Oid ownerId)
{
Relation newRel;
@@ -23277,7 +23318,6 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
List *colList = NIL;
Oid relamId;
Oid namespaceId;
- AlteredTableInfo *new_partrel_tab;
Form_pg_class parent_relform = parent_rel->rd_rel;
/* If the existing rel is temp, it must belong to this session. */
@@ -23396,11 +23436,8 @@ createPartitionTable(List **wqueue, RangeVar *newPartName,
*/
newRel = table_open(newRelId, NoLock);
- /* Find or create a work queue entry for the newly created table. */
- new_partrel_tab = ATGetQueueEntry(wqueue, newRel);
-
/* Create constraints, default values, and generated values. */
- createTableConstraints(wqueue, new_partrel_tab, parent_rel, newRel);
+ createTableConstraints(parent_rel, newRel);
/*
* Need to call CommandCounterIncrement, so a fresh relcache entry has
@@ -23545,15 +23582,20 @@ transferPartitionReplicaIdentity(List *sourceOids, List *newPartRels)
/*
* MergePartitionsMoveRows: scan partitions to be merged (mergingPartitions)
* of the partitioned table and move rows into the new partition
- * (newPartRel). We also verify check constraints against these rows.
+ * (newPartRel).
+ *
+ * The caller has entered a restricted search path, so anything evaluated here
+ * does not resolve names the way the user's session would. Nothing needs
+ * evaluating for a merge: the rows are relocated unchanged, and the cases that
+ * would have required it are rejected beforehand (see
+ * checkPartitionSystemColumnRefs()). Keep it that way, or take the search
+ * path into account.
*/
static void
-MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPartRel)
+MergePartitionsMoveRows(List *mergingPartitions, Relation newPartRel)
{
CommandId mycid;
EState *estate;
- AlteredTableInfo *tab;
- ListCell *ltab;
/*
* The FSM is empty, so don't bother using it. Also suppress logical
@@ -23568,14 +23610,8 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
BulkInsertState bistate; /* state of bulk inserts for partition */
TupleTableSlot *dstslot;
- /* Find the work queue entry for the new partition table: newPartRel. */
- tab = ATGetQueueEntry(wqueue, newPartRel);
-
- /* Generate the constraint and default execution states. */
estate = CreateExecutorState();
- buildExpressionExecutionStates(tab, newPartRel, estate);
-
mycid = GetCurrentCommandId(true);
/* Prepare a BulkInsertState for table_tuple_insert. */
@@ -23651,22 +23687,6 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
ExecStoreVirtualTuple(insertslot);
}
- /*
- * Constraints and GENERATED expressions might reference the
- * tableoid column, so fill tts_tableOid with the desired value.
- * (We must do this each time, because it gets overwritten with
- * newrel's OID during storing.)
- */
- insertslot->tts_tableOid = RelationGetRelid(newPartRel);
-
- /*
- * Now, evaluate any generated expressions whose inputs come from
- * the new tuple. We assume these columns won't reference each
- * other, so that there's no ordering dependency.
- */
- evaluateGeneratedExpressionsAndCheckConstraints(tab, newPartRel,
- insertslot, econtext);
-
/* Write the tuple out to the new relation. */
table_tuple_insert(newPartRel, insertslot, mycid,
ti_options, bistate);
@@ -23690,20 +23710,6 @@ MergePartitionsMoveRows(List **wqueue, List *mergingPartitions, Relation newPart
FreeBulkInsertState(bistate);
table_finish_bulk_insert(newPartRel, ti_options);
-
- /*
- * We don't need to process this newPartRel since we already processed it
- * here, so delete the ALTER TABLE queue for it.
- */
- foreach(ltab, *wqueue)
- {
- tab = (AlteredTableInfo *) lfirst(ltab);
- if (tab->relid == RelationGetRelid(newPartRel))
- {
- *wqueue = list_delete_cell(*wqueue, ltab);
- break;
- }
- }
}
/*
@@ -23963,6 +23969,13 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
int save_sec_context;
int save_nestlevel;
+ /*
+ * The rows are relocated as-is, but a generated column or CHECK
+ * constraint depending on a system column would change meaning in the new
+ * partition.
+ */
+ checkPartitionSystemColumnRefs(rel);
+
/*
* Check ownership of merged partitions - partitions with different owners
* cannot be merged. Also, collect the OIDs of these partitions during the
@@ -23991,6 +24004,14 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
else
ownerId = mergingPartition->rd_rel->relowner;
+ /*
+ * The new partition inherits the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data.
+ */
+ checkPartitionGenExprMatchesParent(rel, mergingPartition);
+
/* Store the next merging partition into the list. */
mergingPartitions = lappend_oid(mergingPartitions,
RelationGetRelid(mergingPartition));
@@ -24090,7 +24111,7 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
* model.
*/
Assert(OidIsValid(ownerId));
- newPartRel = createPartitionTable(wqueue, cmd->name, rel, ownerId);
+ newPartRel = createPartitionTable(cmd->name, rel, ownerId);
/*
* Carry the source partitions' replica identity over to the new
@@ -24115,7 +24136,7 @@ ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation rel,
RestrictSearchPath();
/* Copy data from merged partitions to the new partition. */
- MergePartitionsMoveRows(wqueue, mergingPartitions, newPartRel);
+ MergePartitionsMoveRows(mergingPartitions, newPartRel);
/* Drop the current partitions before attaching the new one. */
foreach_oid(mergingPartitionOid, mergingPartitions)
@@ -24198,30 +24219,13 @@ createSplitPartitionContext(Relation partRel)
* deleteSplitPartitionContext: delete context for partition
*/
static void
-deleteSplitPartitionContext(SplitPartitionContext *pc, List **wqueue, uint32 ti_options)
+deleteSplitPartitionContext(SplitPartitionContext *pc, uint32 ti_options)
{
- ListCell *ltab;
-
ExecDropSingleTupleTableSlot(pc->dstslot);
FreeBulkInsertState(pc->bistate);
table_finish_bulk_insert(pc->partRel, ti_options);
- /*
- * We don't need to process this pc->partRel so delete the ALTER TABLE
- * queue of it.
- */
- foreach(ltab, *wqueue)
- {
- AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
-
- if (tab->relid == RelationGetRelid(pc->partRel))
- {
- *wqueue = list_delete_cell(*wqueue, ltab);
- break;
- }
- }
-
pfree(pc);
}
@@ -24234,9 +24238,15 @@ deleteSplitPartitionContext(SplitPartitionContext *pc, List **wqueue, uint32 ti_
* the partition specification details for all new partitions.
* newPartRels: list of Relations, new partitions created in
* ATExecSplitPartition.
+ *
+ * The caller has entered a restricted search path, so anything evaluated here
+ * does not resolve names the way the user's session would. The partition
+ * constraints checked below are safe in that respect, because functions in a
+ * partition key expression must be IMMUTABLE. Anything added here has to
+ * clear the same bar, or take the search path into account.
*/
static void
-SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
+SplitPartitionMoveRows(Relation rel, Relation splitRel,
List *partlist, List *newPartRels)
{
/*
@@ -24269,11 +24279,6 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
pc = createSplitPartitionContext((Relation) lfirst(listptr2));
- /* Find the work queue entry for the new partition table: newPartRel. */
- pc->tab = ATGetQueueEntry(wqueue, pc->partRel);
-
- buildExpressionExecutionStates(pc->tab, pc->partRel, estate);
-
if (sps->bound->is_default)
{
/*
@@ -24391,22 +24396,6 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
ExecStoreVirtualTuple(insertslot);
}
- /*
- * Constraints and GENERATED expressions might reference the tableoid
- * column, so fill tts_tableOid with the desired value. (We must do
- * this each time, because it gets overwritten with newrel's OID
- * during storing.)
- */
- insertslot->tts_tableOid = RelationGetRelid(pc->partRel);
-
- /*
- * Now, evaluate any generated expressions whose inputs come from the
- * new tuple. We assume these columns won't reference each other, so
- * that there's no ordering dependency.
- */
- evaluateGeneratedExpressionsAndCheckConstraints(pc->tab, pc->partRel,
- insertslot, econtext);
-
/* Write the tuple out to the new relation. */
table_tuple_insert(pc->partRel, insertslot, mycid,
ti_options, pc->bistate);
@@ -24427,7 +24416,7 @@ SplitPartitionMoveRows(List **wqueue, Relation rel, Relation splitRel,
FreeExecutorState(estate);
foreach_ptr(SplitPartitionContext, spc, partContexts)
- deleteSplitPartitionContext(spc, wqueue, ti_options);
+ deleteSplitPartitionContext(spc, ti_options);
}
/*
@@ -24462,6 +24451,16 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
splitRelOid = RelationGetRelid(splitRel);
+ /*
+ * The new partitions inherit the partitioned table's generation
+ * expressions, but rows are moved as-is; reject a split partition whose
+ * generation expression differs, which would otherwise silently store
+ * inconsistent data. Likewise reject expressions depending on a system
+ * column, whose value changes in the new partitions.
+ */
+ checkPartitionSystemColumnRefs(rel);
+ checkPartitionGenExprMatchesParent(rel, splitRel);
+
/* Check descriptions of new partitions. */
foreach_node(SinglePartitionSpec, sps, cmd->partlist)
{
@@ -24537,7 +24536,7 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
{
Relation newPartRel;
- newPartRel = createPartitionTable(wqueue, sps->name, rel,
+ newPartRel = createPartitionTable(sps->name, rel,
splitRel->rd_rel->relowner);
newPartRels = lappend(newPartRels, newPartRel);
}
@@ -24565,7 +24564,7 @@ ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel,
RestrictSearchPath();
/* Copy data from the split partition to the new partitions. */
- SplitPartitionMoveRows(wqueue, rel, splitRel, cmd->partlist, newPartRels);
+ SplitPartitionMoveRows(rel, splitRel, cmd->partlist, newPartRels);
/* Keep the lock until commit. */
table_close(splitRel, NoLock);
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 0c19e5fa93f..10844fd9f9b 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -887,14 +887,14 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -926,7 +926,7 @@ CREATE TRIGGER tp_1_2_before_insert_row_trigger BEFORE INSERT ON tp_1_2 FOR EACH
i | integer | | not null | | plain | | | tp_0_1.i
t | text | | | 'default_tp_0_1'::text | main | | |
b | bigint | | not null | | plain | | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | | |
Partition of: t FOR VALUES FROM (0) TO (1)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 1))
Check constraints:
@@ -1030,37 +1030,50 @@ ERROR: insert or update on table "t_fk" violates foreign key constraint "t_fk_i
DETAIL: Key (i)=(2) is not present in table "t".
DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so MERGE PARTITIONS is rejected. This holds for a stored column,
+-- whose value cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partition (here that
+-- would also break the NOT NULL constraint if the new partition happened to
+-- get the OID mentioned in the expression). A generated column over user
+-- columns only is fine: its value is preserved, as exercised above.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "g" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partition's OID, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+ERROR: cannot merge or split partitions when a check constraint depends on a system column
+DETAIL: Constraint "cc" of relation "t" references a system column such as tableoid.
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i * 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i * 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i * 2), i int);
ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
ALTER TABLE t ADD CHECK (g > 0);
@@ -1070,24 +1083,17 @@ ALTER TABLE t MERGE PARTITIONS (tp_1, tp_2) INTO tp_12;
INSERT INTO t VALUES (16);
-- ERROR
INSERT INTO t VALUES (0);
-ERROR: new row for relation "tp_12" violates check constraint "t_i_check"
+ERROR: new row for relation "tp_12" violates check constraint "t_g_check"
DETAIL: Failing row contains (0, virtual).
-- Should be 3 rows: (5), (15), (16):
-SELECT i FROM t ORDER BY i;
- i
-----
- 5
- 15
- 16
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+----
+ 5 | 10
+ 15 | 30
+ 16 | 32
(3 rows)
--- Should be 1 because for the same tableoid (15 + tableoid) = (5 + tableoid) + 10:
-SELECT count(*) FROM t WHERE i = 15 AND g IN (SELECT g + 10 FROM t WHERE i = 5);
- count
--------
- 1
-(1 row)
-
DROP TABLE t;
-- A merged partition needs its own TOAST table; otherwise an out-of-line
-- varlena value carried over from one of the merging partitions has
@@ -1167,6 +1173,45 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
0
(1 row)
+DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+ i | g
+----+-----
+ 3 | 6
+ 5 | 500
+ 12 | 24
+(3 rows)
+
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_10" has a generation expression different from table "t".
DROP TABLE t;
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
diff --git a/src/test/regress/expected/partition_split.out b/src/test/regress/expected/partition_split.out
index 788ca5a28db..ee6fdb44b5e 100644
--- a/src/test/regress/expected/partition_split.out
+++ b/src/test/regress/expected/partition_split.out
@@ -1547,7 +1547,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
CREATE STATISTICS t_stat (DEPENDENCIES) on i, b from t;
@@ -1576,7 +1576,7 @@ CREATE TRIGGER tp_x_before_insert_row_trigger BEFORE INSERT ON tp_x FOR EACH ROW
i | integer | | not null | | plain | | tp_x.i
t | text | | | 'default_tp_x'::text | main | |
b | bigint | | not null | | plain | |
- d | date | | | generated always as ('02-02-2022'::date) stored | plain | |
+ d | date | | | generated always as ('01-01-2022'::date) stored | plain | |
Partition of: t FOR VALUES FROM (0) TO (2)
Partition constraint: ((abs(i) IS NOT NULL) AND (abs(i) >= 0) AND (abs(i) < 2))
Check constraints:
@@ -1627,32 +1627,60 @@ SELECT tableoid::regclass, * FROM t ORDER BY tableoid::regclass::text COLLATE "C
DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so SPLIT PARTITION is rejected -- for a stored column, whose value
+-- cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 1
-(1 row)
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
- count
--------
- 0
-(1 row)
-
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "tab_id" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partitions.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a generated column depends on a system column
+DETAIL: Column "g" of relation "t" is generated from an expression that references a system column such as tableoid.
+DROP TABLE t;
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partitions' OIDs, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_1');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+ERROR: cannot merge or split partitions when a check constraint depends on a system column
+DETAIL: Constraint "cc" of relation "t" references a system column such as tableoid.
+DROP TABLE t;
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
+ERROR: cannot merge or split partitions when a partition's generation expression differs from the partitioned table
+DETAIL: Generated column "g" of partition "tp_0_20" has a generation expression different from table "t".
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
-- that out-of-line varlena attributes coming from the source partition
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 9c41b252ad3..562fcb3401b 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -649,7 +649,7 @@ CREATE TABLE tp_0_1
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_0_1',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_0_1 FOR VALUES FROM (0) TO (1);
COMMENT ON COLUMN tp_0_1.i IS 'tp_0_1.i';
@@ -657,7 +657,7 @@ CREATE TABLE tp_1_2
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_1_2',
b bigint,
- d date GENERATED ALWAYS as ('2022-03-03') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_1_2 FOR VALUES FROM (1) TO (2);
COMMENT ON COLUMN tp_1_2.i IS 'tp_1_2.i';
@@ -736,33 +736,49 @@ DROP TABLE t_fk;
DROP TABLE t;
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so MERGE PARTITIONS is rejected. This holds for a stored column,
+-- whose value cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
-
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
-ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2;
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partition (here that
+-- would also break the NOT NULL constraint if the new partition happened to
+-- get the OID mentioned in the expression). A generated column over user
+-- columns only is fine: its value is preserved, as exercised above.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
+DROP TABLE t;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partition's OID, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
+CREATE TABLE tp_1_2 PARTITION OF t FOR VALUES FROM (1) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_2');
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t MERGE PARTITIONS (tp_0_1, tp_1_2) INTO tp_0_2; -- fails
DROP TABLE t;
-- Test for generated columns (different order of columns in partitioned table
-- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i * 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i * 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i * 2), i int);
ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
ALTER TABLE t ADD CHECK (g > 0);
@@ -775,9 +791,7 @@ INSERT INTO t VALUES (16);
-- ERROR
INSERT INTO t VALUES (0);
-- Should be 3 rows: (5), (15), (16):
-SELECT i FROM t ORDER BY i;
--- Should be 1 because for the same tableoid (15 + tableoid) = (5 + tableoid) + 10:
-SELECT count(*) FROM t WHERE i = 15 AND g IN (SELECT g + 10 FROM t WHERE i = 5);
+SELECT i, g FROM t ORDER BY i;
DROP TABLE t;
@@ -839,6 +853,40 @@ SELECT reltablespace FROM pg_class WHERE relname = 'tp_merged';
DROP TABLE t;
+-- MERGE PARTITIONS preserves stored generated column values rather than
+-- recomputing them (here the partitioned table's generation expression differs
+-- from what actually produced the stored rows because the function changed).
+CREATE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 2';
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (merge_gen(i)) STORED)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_10 PARTITION OF t FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (3), (12);
+CREATE OR REPLACE FUNCTION merge_gen(i int) RETURNS int IMMUTABLE LANGUAGE sql AS 'SELECT i * 100';
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20;
+-- Existing rows keep g = i * 2 (3->6, 12->24); only a fresh insert uses the new
+-- expression (5->500).
+INSERT INTO t VALUES (5);
+SELECT i, g FROM t ORDER BY i;
+DROP TABLE t;
+DROP FUNCTION merge_gen(int);
+
+
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, MERGE PARTITIONS is rejected in that case: otherwise the new
+-- partition would store data inconsistent with its own generation expression
+-- (and here even violate NOT NULL).
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (NULLIF(id, 1)) STORED NOT NULL)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_10 (g int GENERATED ALWAYS AS (id) STORED NOT NULL, id int);
+ALTER TABLE t ATTACH PARTITION tp_0_10 FOR VALUES FROM (0) TO (10);
+CREATE TABLE tp_10_20 PARTITION OF t FOR VALUES FROM (10) TO (20);
+INSERT INTO t VALUES (2), (12);
+ALTER TABLE t MERGE PARTITIONS (tp_0_10, tp_10_20) INTO tp_0_20; -- fails
+DROP TABLE t;
+
+
-- MERGE PARTITIONS carries over a uniform replica identity ...
CREATE TABLE t (i int PRIMARY KEY) PARTITION BY RANGE (i);
CREATE TABLE tp_0_1 PARTITION OF t FOR VALUES FROM (0) TO (1);
diff --git a/src/test/regress/sql/partition_split.sql b/src/test/regress/sql/partition_split.sql
index c470c42be71..db383c1ff30 100644
--- a/src/test/regress/sql/partition_split.sql
+++ b/src/test/regress/sql/partition_split.sql
@@ -1122,7 +1122,7 @@ CREATE TABLE tp_x
(i int NOT NULL,
t text STORAGE MAIN DEFAULT 'default_tp_x',
b bigint,
- d date GENERATED ALWAYS as ('2022-02-02') STORED);
+ d date GENERATED ALWAYS as ('2022-01-01') STORED);
ALTER TABLE t ATTACH PARTITION tp_x FOR VALUES FROM (0) TO (2);
COMMENT ON COLUMN tp_x.i IS 'tp_x.i';
@@ -1162,26 +1162,57 @@ DROP TABLE t;
DROP FUNCTION trigger_function();
--- Test for recomputation of stored generated columns.
+-- A generated column whose expression references a system column (tableoid) is
+-- the one whose value legitimately changes when a row is relocated to another
+-- partition, so SPLIT PARTITION is rejected -- for a stored column, whose value
+-- cannot be recomputed while re-verifying all constraints ...
CREATE TABLE t (i int, tab_id int generated always as (tableoid) stored) PARTITION BY RANGE (i);
CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
-ALTER TABLE t ADD CONSTRAINT cc CHECK(tableoid <> 123456789);
INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
+
+-- ... and for a virtual one, which is not stored at all, so its value would
+-- silently change as soon as the rows live in the new partitions.
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (NULLIF(tableoid, 18470)) NOT NULL)
+ PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+INSERT INTO t VALUES (0), (1);
+ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
+ (PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
--- Should be 1 because partition identifier for row with i=0 is the same as
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
--- "tab_id" column (stored generated column) with "tableoid" attribute requires
--- recomputation here.
+-- A CHECK constraint referencing a system column is rejected for the same
+-- reason: it would have to be re-verified against the new partitions' OIDs, and
+-- the row movement runs with a restricted search_path, so a search_path
+-- dependent expression would not even evaluate the way it does for an INSERT.
+CREATE TABLE t (i int) PARTITION BY RANGE (i);
+CREATE TABLE tp_0_2 PARTITION OF t FOR VALUES FROM (0) TO (2);
+ALTER TABLE t ADD CONSTRAINT cc CHECK (tableoid::regclass::text <> 'tp_0_1');
+INSERT INTO t VALUES (0), (1);
ALTER TABLE t SPLIT PARTITION tp_0_2 INTO
(PARTITION tp_0_1 FOR VALUES FROM (0) TO (1),
- PARTITION tp_1_2 FOR VALUES FROM (1) TO (2));
+ PARTITION tp_1_2 FOR VALUES FROM (1) TO (2)); -- fails
+DROP TABLE t;
--- Should be 0 because partition identifier for row with i=0 is different from
--- partition identifier for row with i=1.
-SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i = 1);
+-- A partition can carry a generation expression different from the partitioned
+-- table's (ATTACH PARTITION does not compare the expressions). Since values are
+-- moved as-is, SPLIT PARTITION is rejected in that case: otherwise the new
+-- partitions would store data inconsistent with their own generation
+-- expression.
+CREATE TABLE t (id int, g int GENERATED ALWAYS AS (id * 2) STORED)
+ PARTITION BY RANGE (id);
+CREATE TABLE tp_0_20 (id int, g int GENERATED ALWAYS AS (id * 100) STORED);
+ALTER TABLE t ATTACH PARTITION tp_0_20 FOR VALUES FROM (0) TO (20);
+INSERT INTO t VALUES (3), (12);
+ALTER TABLE t SPLIT PARTITION tp_0_20 INTO
+ (PARTITION tp_0_10 FOR VALUES FROM (0) TO (10),
+ PARTITION tp_10_20 FOR VALUES FROM (10) TO (20)); -- fails
DROP TABLE t;
-- Each new partition produced by SPLIT must get its own TOAST table so
--
2.55.0
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-21 09:36 Zsolt Parragi <zsolt.parragi@percona.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
1 sibling, 1 reply; 40+ messages in thread
From: Zsolt Parragi @ 2026-08-21 09:36 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, jian he <jian.universality@gmail.com>; Melanie Plageman <melanieplageman@gmail.com>
I don't think 0005 is correct. Similar to the other changes, the
intention should be "do not change the table access method in an
invisible way to the user".
The current patch still drops explicit table AMs for a partition
(CREATE TABLE tp PARTITION OF ... USING otheram) in favor of the
parent AM or even the current session default AM.
Merge of partitions with different AMs still succeed.
I have to agree with Daniel's reasoning earlier:
> Apart from the obviously dangerous ones like RLS and ACL, silently dropping the
> table AM may induce side-effects which are hard for us to even reason about
> since they are external to the core code.
As an example, we have pg_tde which provides an encrypted version of
the heap AM. Silently changing the table AM in our case means that we
remove encryption from the data without notifying the user about it.
We can detect such commands in an event trigger and disable them to
prevent accidents, but I don't think this should be left to extension
authors.
> As I mentioned in [1], I think this is the way to save this feature
> for pg19. I think it's too late to introduce new (and debatable)
> functionality.
My opinion is that things like silently dropping triggers or default
values or constraints can result in similar dangerous accidents.
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-21 13:48 Melanie Plageman <melanieplageman@gmail.com>
parent: Zsolt Parragi <zsolt.parragi@percona.com>
0 siblings, 1 reply; 40+ messages in thread
From: Melanie Plageman @ 2026-08-21 13:48 UTC (permalink / raw)
To: Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: Alexander Korotkov <aekorotkov@gmail.com>; pgsql-hackers@lists.postgresql.org, jian he <jian.universality@gmail.com>
On Fri, Aug 21, 2026 at 5:36 AM Zsolt Parragi <zsolt.parragi@percona.com> wrote:
>
> I don't think 0005 is correct. Similar to the other changes, the
> intention should be "do not change the table access method in an
> invisible way to the user".
>
> The current patch still drops explicit table AMs for a partition
> (CREATE TABLE tp PARTITION OF ... USING otheram) in favor of the
> parent AM or even the current session default AM.
> Merge of partitions with different AMs still succeed.
>
> I have to agree with Daniel's reasoning earlier:
>
> > Apart from the obviously dangerous ones like RLS and ACL, silently dropping the
> > table AM may induce side-effects which are hard for us to even reason about
> > since they are external to the core code.
>
> As an example, we have pg_tde which provides an encrypted version of
> the heap AM. Silently changing the table AM in our case means that we
> remove encryption from the data without notifying the user about it.
> We can detect such commands in an event trigger and disable them to
> prevent accidents, but I don't think this should be left to extension
> authors.
>
> > As I mentioned in [1], I think this is the way to save this feature
> > for pg19. I think it's too late to introduce new (and debatable)
> > functionality.
>
> My opinion is that things like silently dropping triggers or default
> values or constraints can result in similar dangerous accidents.
I thought we were going to disallow using merge/split on child
partitions with any differences from the parent partition at all. That
way copying everything from the parent would work fine. That seems
like the way forward to me at this point.
- Melanie
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-21 14:17 Alexander Korotkov <aekorotkov@gmail.com>
parent: Melanie Plageman <melanieplageman@gmail.com>
0 siblings, 1 reply; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-21 14:17 UTC (permalink / raw)
To: Melanie Plageman <melanieplageman@gmail.com>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org, jian he <jian.universality@gmail.com>
On Fri, Aug 21, 2026 at 4:49 PM Melanie Plageman
<melanieplageman@gmail.com> wrote:
> On Fri, Aug 21, 2026 at 5:36 AM Zsolt Parragi <zsolt.parragi@percona.com> wrote:
> >
> > I don't think 0005 is correct. Similar to the other changes, the
> > intention should be "do not change the table access method in an
> > invisible way to the user".
> >
> > The current patch still drops explicit table AMs for a partition
> > (CREATE TABLE tp PARTITION OF ... USING otheram) in favor of the
> > parent AM or even the current session default AM.
> > Merge of partitions with different AMs still succeed.
> >
> > I have to agree with Daniel's reasoning earlier:
> >
> > > Apart from the obviously dangerous ones like RLS and ACL, silently dropping the
> > > table AM may induce side-effects which are hard for us to even reason about
> > > since they are external to the core code.
> >
> > As an example, we have pg_tde which provides an encrypted version of
> > the heap AM. Silently changing the table AM in our case means that we
> > remove encryption from the data without notifying the user about it.
> > We can detect such commands in an event trigger and disable them to
> > prevent accidents, but I don't think this should be left to extension
> > authors.
> >
> > > As I mentioned in [1], I think this is the way to save this feature
> > > for pg19. I think it's too late to introduce new (and debatable)
> > > functionality.
> >
> > My opinion is that things like silently dropping triggers or default
> > values or constraints can result in similar dangerous accidents.
>
> I thought we were going to disallow using merge/split on child
> partitions with any differences from the parent partition at all. That
> way copying everything from the parent would work fine. That seems
> like the way forward to me at this point.
+1, that was the way forward I was going to propose. I'm going to
post the patch later today.
------
Regards,
Alexander Korotkov
Supabase
^ permalink raw reply [nested|flat] 40+ messages in thread
* RE: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-23 06:37 Zhijie Hou (Fujitsu) <houzj.fnst@fujitsu.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
1 sibling, 1 reply; 40+ messages in thread
From: Zhijie Hou (Fujitsu) @ 2026-08-23 06:37 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: Melanie Plageman <melanieplageman@gmail.com>; Daniel Gustafsson <daniel@yesql.se>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>; jian he <jian.universality@gmail.com>
Hi,
On Thursday, August 20, 2026 8:46 PM Alexander Korotkov <aekorotkov@gmail.com> wrote:
>
> Added as 0005 patch to the patchset.
>
I took a look at 0001 and 0002.
0001 looks OK to me.
For 0002, I think we should also disallow the command when the partition is
explicitly listed in the publication's EXCEPT TABLE list. Otherwise, changes on
partitions that were previously ignored would start being replicated after
splitting (or merging), which could be unexpected.
BTW, this patch also disallows SPLIT/MERGE when both the parent and child tables
are explicitly added to a publication. That case is actually safe, since
publishing the parent already covers the partition. But if the intent is to keep
the check simple and avoid adding complexity for this infrequent case, I think
that's acceptable - though it might be worth adding a comment to explain the
reasoning.
Best Regards,
Zhijie Hou
^ permalink raw reply [nested|flat] 40+ messages in thread
* RE: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-23 09:17 Zhijie Hou (Fujitsu) <houzj.fnst@fujitsu.com>
parent: Zhijie Hou (Fujitsu) <houzj.fnst@fujitsu.com>
0 siblings, 0 replies; 40+ messages in thread
From: Zhijie Hou (Fujitsu) @ 2026-08-23 09:17 UTC (permalink / raw)
To: Zhijie Hou (Fujitsu) <houzj.fnst@fujitsu.com>; Alexander Korotkov <aekorotkov@gmail.com>; +Cc: Melanie Plageman <melanieplageman@gmail.com>; Daniel Gustafsson <daniel@yesql.se>; Zsolt Parragi <zsolt.parragi@percona.com>; PostgreSQL Hackers <pgsql-hackers@postgresql.org>; jian he <jian.universality@gmail.com>
On Sunday, August 23, 2026 3:37 PM Zhijie Hou (Fujitsu) <houzj.fnst@fujitsu.com> wrote:
>
> On Thursday, August 20, 2026 8:46 PM Alexander Korotkov
> <aekorotkov@gmail.com> wrote:
> >
> > Added as 0005 patch to the patchset.
> >
>
> I took a look at 0001 and 0002.
>
> 0001 looks OK to me.
>
> For 0002, I think we should also disallow the command when the partition is
> explicitly listed in the publication's EXCEPT TABLE list. Otherwise, changes on
> partitions that were previously ignored would start being replicated after
> splitting (or merging), which could be unexpected.
After rechecking, I realized that adding partition into EXCEPT TABLE list is not
supported for now, so I think the current check is sufficient, so please ignore
the above comment.
>
> BTW, this patch also disallows SPLIT/MERGE when both the parent and child
> tables are explicitly added to a publication. That case is actually safe, since
> publishing the parent already covers the partition. But if the intent is to keep
> the check simple and avoid adding complexity for this infrequent case, I think
> that's acceptable - though it might be worth adding a comment to explain the
> reasoning.
In addition to the above point, I noticed a similar case in
checkPartitionSchemaPublications():
+/*
+ * checkPartitionSchemaPublications: refuse MERGE/SPLIT when the new partition(s)
+ * would land in a schema whose FOR TABLES IN SCHEMA publications differ from
+ * those of the source partition(s).
The function decides solely by comparing the schemas' FOR TABLES IN SCHEMA
publications. But a publication can mix both forms, e.g.:
CREATE PUBLICATION pub FOR TABLES IN SCHEMA sch1, TABLE parent;
Since the publication covers the partitioned table itself, all of its partitions
are implicitly covered no matter which schema they live in. So even if the new
partition lands in a different schema after MERGE/SPLIT, its coverage by such a
publication doesn't change, and refusing the operation seems unnecessary. If
releasing this case looks complex, adding some comments would be better.
Best Regards,
Zhijie Hou
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-24 10:39 jian he <jian.universality@gmail.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
0 siblings, 1 reply; 40+ messages in thread
From: jian he @ 2026-08-24 10:39 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: Melanie Plageman <melanieplageman@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
On Fri, Aug 21, 2026 at 10:17 PM Alexander Korotkov
<aekorotkov@gmail.com> wrote:
>
> +1, that was the way forward I was going to propose. I'm going to
> post the patch later today.
>
We currently don't have a mechanism to recreate triggers. Therefore, for now, we
should error out when ALTER TABLE MERGE PARTITIONS is performed on partitioned
tables or partitions that have triggers.
We should also error out in the following cases:
* A partition has CHECK or NOT NULL constraints where conislocal = true.
* A partition has a local index that is not part of the partition
index hierarchy.
* The partition column's default differs from the partitioned table's
column default.
* The partition table's access method differs from that of the
partitioned table.
I think we can allow STATISTICS and COMMENTS because these objects
were not cascaded to child tables when created.
Should we also error out when the column compression method differs,
or when the column's pg_type.typstorage differs?
What do you think?
The attached patch (based on v6) is very rough; I will polish it later.
Attachments:
[application/octet-stream] disallow_more_cases_merge_partition.nocfbot (20.3K, ../../CACJufxGzR4vJZJNTULW2tfrx0jbpetB89M7nNirXLGOLfSQ3Lw@mail.gmail.com/2-disallow_more_cases_merge_partition.nocfbot)
download
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-24 22:16 Robert Haas <robertmhaas@gmail.com>
parent: jian he <jian.universality@gmail.com>
0 siblings, 2 replies; 40+ messages in thread
From: Robert Haas @ 2026-08-24 22:16 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: Alexander Korotkov <aekorotkov@gmail.com>; Melanie Plageman <melanieplageman@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
On Mon, Aug 24, 2026 at 6:40 AM jian he <jian.universality@gmail.com> wrote:
> The attached patch (based on v6) is very rough; I will polish it later.
I hate to be a downer here, but I don't really understand why this
feature got committed (or, well, re-committed) in the first place, and
I don't understand why it hasn't been reverted yet. What does this
actually do that anyone would want or find advantageous?
I admit to being a skeptic of this kind of feature on general
principle, so take everything I have to say here with an
appropriately-sized amount of salt. But it seems to me that the major
arguments for a feature like this would be if it either (1) makes the
new partitions that it creates really good clones of the original
partition or (2) does something to minimize data movement or (3) finds
clever ways to reduce the amount of locking required. As to (1), even
Alexander seems not entirely satisfied with the current behavior and
proposes that it be changed in a future release, but that's a
backward-incompatibility that we should be reluctant to introduce. As
to (2), the source partitions are always copied in their entirety to
new partitions, which is probably a pretty fair strategy when
splitting a partition into equal parts or merging roughly equal-size
partitions, but very non-optimal when the splits or merges are very
lopsided. The point here isn't that the strategy is horrible but that
there's no particular intelligence here; you can easily do the same
thing by hand. As to (3), the patch takes AccessExclusiveLock on the
partition parent for the entire duration of the operation. This seems
non-viable in practice. I suspect that essentially 100% of users will
prefer to quiesce writes to the partition to be split or merged,
create new partitions with the same data, and then use ATTACH/DETACH
CONCURRENTLY to do the swap.
To go into a little more detail about (1), I asked Claude to analyze,
in the current code, which partition properties are set from the
original partition vs. which ones are set as they would be from a new
partition. Basically, it says that [A] ownership is copied from the
source partition(s), apparently in response to my 2024 complaint, and
[B] DEPENDS ON EXTENSION markers on indexes are copied from source
partitions. According to Claude, everything else is identical to what
you would get with CREATE TABLE ... PARTITION OF, except that when the
parent has no AM set, the default is heap rather than
default_table_access_method, which is a bug. This seems like a very
disappointing state of affairs, not so much because of the bug, but
just because it doesn't seem at all principled. Like, why those two
things, and nothing else? The ALTER INDEX .. DEPENDS ON EXTENSION
thing was introduced by 713e553e321 and is a result of the fact that
the indexes are re-cloned from the parent rather than the source
partition -- but there is also ALTER TRIGGER ... DEPENDS ON EXTENSION,
which wasn't changed. I think what almost everyone has said is that
they want clone-of-source-partition behavior, not
new-partition-of-parent behavior, but
almost-new-partition-of-parent-but-with-a-few-random-exceptions seems
almost worse. Those exceptions aren't curing the basic design problem
here; they're only obscuring it.
So what we have here is a feature that has none of the advantages that
I listed above that might potentially make it compelling and that is
also full of bugs. You can only use it if you don't mind
AccessExclusiveLock for the entire operation AND the only available
data-movement strategy is the right one for your use case AND your
partitions are not customized in any way that makes
new-partition-of-parent behavior a problem (modulo [A] and [B] in the
previous paragraph). I feel like that must be very nearly the empty
set of users. On top of that, Zsolt's email at the start of this
thread basically said that it broke replication and was broken with
generated columns, and we're now just weeks away from when we're
supposed to be releasing 19 and that stuff is still broken and it's
not clear that we have satisfying fixes for all of it. As I say, I'm
skeptical about this kind of feature in general, so, again, take what
I have to say with a grain of salt, but isn't that more than
sufficient grounds for a revert? I feel like even if all the patches
that we have now for all of the issues discussed on this thread are
perfectly committable day (and the bit I quote from Jian above says
otherwise) that still wouldn't turn this into a clean design and it
would still be unclear who would want to use the feature.
To be clear, I'm not trying to say that this patch needs to do every
single thing that anyone could want out of a feature of this type, but
it needs to do something well enough to satisfy some use case. A
feature that did the clone-from-the-original-partition thing well
would be usable for really small tables even if the locking behavior
and data-copying were not improved. A feature that did clever things
with locking and data-copying would be used in practice even if the
new partitions had to be manually fixed up afterwards. But the feature
we have isn't usable in either of those cases, and I can't think of a
real case in which it would be usable, plus it has serious bugs four
months after feature freeze.
--
Robert Haas
EDB: http://www.enterprisedb.com
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-26 09:05 jian he <jian.universality@gmail.com>
parent: Robert Haas <robertmhaas@gmail.com>
1 sibling, 2 replies; 40+ messages in thread
From: jian he @ 2026-08-26 09:05 UTC (permalink / raw)
To: Robert Haas <robertmhaas@gmail.com>; +Cc: Alexander Korotkov <aekorotkov@gmail.com>; Melanie Plageman <melanieplageman@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
On Tue, Aug 25, 2026 at 6:17 AM Robert Haas <robertmhaas@gmail.com> wrote:
>
> On Mon, Aug 24, 2026 at 6:40 AM jian he <jian.universality@gmail.com> wrote:
> > The attached patch (based on v6) is very rough; I will polish it later.
>
> I hate to be a downer here, but I don't really understand why this
> feature got committed (or, well, re-committed) in the first place, and
> I don't understand why it hasn't been reverted yet. What does this
> actually do that anyone would want or find advantageous?
>
> I admit to being a skeptic of this kind of feature on general
> principle, so take everything I have to say here with an
> appropriately-sized amount of salt. But it seems to me that the major
> arguments for a feature like this would be if it either (1) makes the
> new partitions that it creates really good clones of the original
> partition or (2) does something to minimize data movement or (3) finds
> clever ways to reduce the amount of locking required. As to (1), even
> Alexander seems not entirely satisfied with the current behavior and
> proposes that it be changed in a future release, but that's a
> backward-incompatibility that we should be reluctant to introduce. As
> to (2), the source partitions are always copied in their entirety to
> new partitions, which is probably a pretty fair strategy when
> splitting a partition into equal parts or merging roughly equal-size
> partitions, but very non-optimal when the splits or merges are very
> lopsided. The point here isn't that the strategy is horrible but that
> there's no particular intelligence here; you can easily do the same
> thing by hand. As to (3), the patch takes AccessExclusiveLock on the
> partition parent for the entire duration of the operation. This seems
> non-viable in practice. I suspect that essentially 100% of users will
> prefer to quiesce writes to the partition to be split or merged,
> create new partitions with the same data, and then use ATTACH/DETACH
> CONCURRENTLY to do the swap.
>
Hi.
I believe the original design first tries to lock the partitioned
table with AccessExclusiveLock.
Then, later patches incrementally reduce the lock level.
Achieving the same result with ATTACH PARTITION requires the user to scan the
data out, store it somewhere, and then scan it back in. MERGE/SPLIT PARTITION
needs only a single scan. If we can lower the lock level on the
parent table as well
that would make the feature clearly worthwhile.
-------------------------------------
Summary of the attached patch, which is based on the previous v6 patchset:
Reject MERGE/SPLIT PARTITION when the partitioned table or a source partition
has a trigger, or when a source partition has a local constraint (conislocal), a
local index (relispartition = false), a column default or generation expression
differing from the partitioned table's, a different access method, or is
unlogged. Internal triggers count, so this also rejects any partitioned table
involved in a foreign key constraint.
That's a lot of ereport(ERROR) messages; I hope this makes it more bullet-proof.
Claude mentioned that some items are still pending, such as ACLs,
tablespace, and reloptions.
The new partition losing reloptions should not be a big deal?
I'm not so sure about the other two.
--
jian
https://www.enterprisedb.com/
Attachments:
[application/octet-stream] v8-0001-Disallow-more-cases-for-partition-merge-split.nocfbot (50.0K, ../../CACJufxEdoB=_enAHwMFTiLFsH47WU3Jy6nVgYu6SJy1jPHh3OA@mail.gmail.com/2-v8-0001-Disallow-more-cases-for-partition-merge-split.nocfbot)
download
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-26 16:37 Nathan Bossart <nathandbossart@gmail.com>
parent: jian he <jian.universality@gmail.com>
1 sibling, 1 reply; 40+ messages in thread
From: Nathan Bossart @ 2026-08-26 16:37 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: Robert Haas <robertmhaas@gmail.com>; Alexander Korotkov <aekorotkov@gmail.com>; Melanie Plageman <melanieplageman@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
[RMT hat]
Unfortunately, since this feature still requires substantial changes and
there are remaining questions on its design, the RMT has decided to request
a revert for v19. Alexander, can you please do so within the next week? I
acknowledge that this is a frustrating outcome for those who worked hard on
it, but I hope the post-feature-freeze discussion has illuminated what
needs to happen for successful inclusion in v20.
--
nathan
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-26 16:42 Alexander Korotkov <aekorotkov@gmail.com>
parent: Nathan Bossart <nathandbossart@gmail.com>
0 siblings, 0 replies; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-26 16:42 UTC (permalink / raw)
To: Nathan Bossart <nathandbossart@gmail.com>; +Cc: jian he <jian.universality@gmail.com>; Robert Haas <robertmhaas@gmail.com>; Melanie Plageman <melanieplageman@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
On Wed, Aug 26, 2026 at 7:37 PM Nathan Bossart <nathandbossart@gmail.com> wrote:
>
> [RMT hat]
>
> Unfortunately, since this feature still requires substantial changes and
> there are remaining questions on its design, the RMT has decided to request
> a revert for v19. Alexander, can you please do so within the next week? I
> acknowledge that this is a frustrating outcome for those who worked hard on
> it, but I hope the post-feature-freeze discussion has illuminated what
> needs to happen for successful inclusion in v20.
Thank you for the notification. I actually came to the same
conclusion, and preparing the revert commit.
------
Regards,
Alexander Korotkov
Supabase
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-26 23:39 Alexander Korotkov <aekorotkov@gmail.com>
parent: jian he <jian.universality@gmail.com>
1 sibling, 0 replies; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-26 23:39 UTC (permalink / raw)
To: jian he <jian.universality@gmail.com>; +Cc: Robert Haas <robertmhaas@gmail.com>; Melanie Plageman <melanieplageman@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
On Wed, Aug 26, 2026 at 12:06 PM jian he <jian.universality@gmail.com> wrote:
> I believe the original design first tries to lock the partitioned
> table with AccessExclusiveLock.
> Then, later patches incrementally reduce the lock level.
>
> Achieving the same result with ATTACH PARTITION requires the user to scan the
> data out, store it somewhere, and then scan it back in. MERGE/SPLIT PARTITION
> needs only a single scan. If we can lower the lock level on the
> parent table as well
> that would make the feature clearly worthwhile.
One can avoid the second scan by initially added relevant CHECK
constraint to the table, which is going to be the a new partition.
That allow subsequent ALTER TABLE ... ATTACH PARTITION to skip the
scan. Thus, manual procedure can at least avoid blocking reader for
long.
------
Regards,
Alexander Korotkov
Supabase
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-27 20:24 Alexander Korotkov <aekorotkov@gmail.com>
parent: Robert Haas <robertmhaas@gmail.com>
1 sibling, 1 reply; 40+ messages in thread
From: Alexander Korotkov @ 2026-08-27 20:24 UTC (permalink / raw)
To: Robert Haas <robertmhaas@gmail.com>; +Cc: jian he <jian.universality@gmail.com>; Melanie Plageman <melanieplageman@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
On Tue, Aug 25, 2026 at 1:17 AM Robert Haas <robertmhaas@gmail.com> wrote:
>
> On Mon, Aug 24, 2026 at 6:40 AM jian he <jian.universality@gmail.com> wrote:
> > The attached patch (based on v6) is very rough; I will polish it later.
>
> I hate to be a downer here, but I don't really understand why this
> feature got committed (or, well, re-committed) in the first place, and
> I don't understand why it hasn't been reverted yet. What does this
> actually do that anyone would want or find advantageous?
>
> I admit to being a skeptic of this kind of feature on general
> principle, so take everything I have to say here with an
> appropriately-sized amount of salt. But it seems to me that the major
> arguments for a feature like this would be if it either (1) makes the
> new partitions that it creates really good clones of the original
> partition or (2) does something to minimize data movement or (3) finds
> clever ways to reduce the amount of locking required. As to (1), even
> Alexander seems not entirely satisfied with the current behavior and
> proposes that it be changed in a future release, but that's a
> backward-incompatibility that we should be reluctant to introduce. As
> to (2), the source partitions are always copied in their entirety to
> new partitions, which is probably a pretty fair strategy when
> splitting a partition into equal parts or merging roughly equal-size
> partitions, but very non-optimal when the splits or merges are very
> lopsided. The point here isn't that the strategy is horrible but that
> there's no particular intelligence here; you can easily do the same
> thing by hand. As to (3), the patch takes AccessExclusiveLock on the
> partition parent for the entire duration of the operation. This seems
> non-viable in practice. I suspect that essentially 100% of users will
> prefer to quiesce writes to the partition to be split or merged,
> create new partitions with the same data, and then use ATTACH/DETACH
> CONCURRENTLY to do the swap.
Yes, I agree. Even the first implementation shouldn't be worse than
what user can manually do: block writers only most of the time, and
block writers and readers only for the short window to swap the
tables. And there should be the clear way to make it work like REPACK
CONCURRENTLY to allow both readers and writers most of the time.
> To go into a little more detail about (1), I asked Claude to analyze,
> in the current code, which partition properties are set from the
> original partition vs. which ones are set as they would be from a new
> partition. Basically, it says that [A] ownership is copied from the
> source partition(s), apparently in response to my 2024 complaint, and
> [B] DEPENDS ON EXTENSION markers on indexes are copied from source
> partitions. According to Claude, everything else is identical to what
> you would get with CREATE TABLE ... PARTITION OF, except that when the
> parent has no AM set, the default is heap rather than
> default_table_access_method, which is a bug. This seems like a very
> disappointing state of affairs, not so much because of the bug, but
> just because it doesn't seem at all principled. Like, why those two
> things, and nothing else? The ALTER INDEX .. DEPENDS ON EXTENSION
> thing was introduced by 713e553e321 and is a result of the fact that
> the indexes are re-cloned from the parent rather than the source
> partition -- but there is also ALTER TRIGGER ... DEPENDS ON EXTENSION,
> which wasn't changed. I think what almost everyone has said is that
> they want clone-of-source-partition behavior, not
> new-partition-of-parent behavior, but
> almost-new-partition-of-parent-but-with-a-few-random-exceptions seems
> almost worse. Those exceptions aren't curing the basic design problem
> here; they're only obscuring it.
You're right, too many design question arise, and too late to resolve
them. Reverted.
------
Regards,
Alexander Korotkov
Supabase
^ permalink raw reply [nested|flat] 40+ messages in thread
* Re: MERGE/SPLIT PARTITIONS issues/questions
@ 2026-08-27 20:40 Robert Haas <robertmhaas@gmail.com>
parent: Alexander Korotkov <aekorotkov@gmail.com>
0 siblings, 0 replies; 40+ messages in thread
From: Robert Haas @ 2026-08-27 20:40 UTC (permalink / raw)
To: Alexander Korotkov <aekorotkov@gmail.com>; +Cc: jian he <jian.universality@gmail.com>; Melanie Plageman <melanieplageman@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
On Thu, Aug 27, 2026 at 4:24 PM Alexander Korotkov <aekorotkov@gmail.com> wrote:
> You're right, too many design question arise, and too late to resolve
> them. Reverted.
Thanks, and sorry about that. I know you put a ton of work into this.
--
Robert Haas
EDB: http://www.enterprisedb.com
^ permalink raw reply [nested|flat] 40+ messages in thread
end of thread, other threads:[~2026-08-27 20:40 UTC | newest]
Thread overview: 40+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-07-23 11:59 MERGE/SPLIT PARTITIONS issues/questions Zsolt Parragi <zsolt.parragi@percona.com>
2026-07-23 14:29 ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-08-01 09:49 ` jian he <jian.universality@gmail.com>
2026-08-01 11:11 ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-08-02 04:27 ` jian he <jian.universality@gmail.com>
2026-08-02 06:56 ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-08-03 19:26 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-05 01:27 ` jian he <jian.universality@gmail.com>
2026-08-05 17:02 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-06 03:45 ` jian he <jian.universality@gmail.com>
2026-08-06 22:59 ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-08-11 21:36 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-12 20:38 ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-08-12 20:48 ` Melanie Plageman <melanieplageman@gmail.com>
2026-08-12 20:56 ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-08-14 14:07 ` Daniel Gustafsson <daniel@yesql.se>
2026-08-14 14:51 ` Melanie Plageman <melanieplageman@gmail.com>
2026-08-14 15:49 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-17 10:27 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-17 10:31 ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-08-17 10:35 ` Daniel Gustafsson <daniel@yesql.se>
2026-08-19 11:31 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-18 03:47 ` jian he <jian.universality@gmail.com>
2026-08-19 11:58 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-20 07:41 ` jian he <jian.universality@gmail.com>
2026-08-20 11:46 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-21 09:36 ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-08-21 13:48 ` Melanie Plageman <melanieplageman@gmail.com>
2026-08-21 14:17 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-24 10:39 ` jian he <jian.universality@gmail.com>
2026-08-24 22:16 ` Robert Haas <robertmhaas@gmail.com>
2026-08-26 09:05 ` jian he <jian.universality@gmail.com>
2026-08-26 16:37 ` Nathan Bossart <nathandbossart@gmail.com>
2026-08-26 16:42 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-26 23:39 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-27 20:24 ` Alexander Korotkov <aekorotkov@gmail.com>
2026-08-27 20:40 ` Robert Haas <robertmhaas@gmail.com>
2026-08-23 06:37 ` Zhijie Hou (Fujitsu) <houzj.fnst@fujitsu.com>
2026-08-23 09:17 ` Zhijie Hou (Fujitsu) <houzj.fnst@fujitsu.com>
2026-08-14 14:36 ` Nathan Bossart <nathandbossart@gmail.com>
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox