pg.ddx.io pgsql-hackers@postgresql.org mailing list archive
help / color / mirror / Atom feedBUG #19686: Rolling back SET TABLESPACE
33+ messages / 7 participants
[nested] [flat]
* BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-16 22:06 Alexandre Felipe <o.alexandre.felipe@gmail.com>
0 siblings, 2 replies; 33+ messages in thread
From: Alexandre Felipe @ 2026-09-16 22:06 UTC (permalink / raw)
To: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
This patchset addresses the issue reported on the pgsql-bugs [1]
The root cause is that after the relation files are copied to a new
tablespace queries
update the index in place but the heap is updated only in the tablespace
copy. If
the transaction is rolled back, the index and the heap becomes inconsistent.
First I tried to fix the crash, easy for btree, manageable for hash, but
for GiST that
would not be feasible, AFAIK would have to perform array searches possibly
over
multiple pages. Later thinking about this I noticed something I didn't
realise on my
first read.
Even without inserting duplicates, and no crashes, it can produce incorrect
results.
SET enable_seqscan = off;
SET enable_bitmapscan = off;
SET allow_in_place_tablespaces = true;
CREATE TABLESPACE ts LOCATION '';
CREATE TABLE t(a int);
CREATE INDEX ON t(a);
BEGIN;
ALTER TABLE t SET TABLESPACE ts;
INSERT INTO t VALUES (0); -- this adds (0, 1) | 0 to the index in the ts
copy
ROLLBACK;
INSERT INTO t VALUES (41); -- this adds (0, 1) | 41 in the default
tablespace
SELECT ctid, a FROM t WHERE a = 0;
ctid | a
-------+----
(0,1) | 41
(1 row)
So, I decided to fix the root cause: modifying a non-durable copy of the
file.
I thought it would be way harder, but the code was architected well enough
that I could save a list of deferred copies, and keep modifying the the
table
in place. If the transaction is rolled back all the tuples in the index
will have
its (possibly dead) in the heap, effectively reserving those TID, this
prevents
both the insertion of duplicates, and the resuscitation of dead tuples by
later changes.
[1]
https://www.postgresql.org/message-id/19686-30f4da834585129f%40postgresql.org
Attachments:
[application/octet-stream] v1-0001-nbtree-skip-insertion-of-existing-tuples.patch (6.2K, ../../CAE8JnxP+Ubdj-kaxfgNt=3_FzD3GgOc3gEvPAvrXKfgTgH2q5w@mail.gmail.com/3-v1-0001-nbtree-skip-insertion-of-existing-tuples.patch)
download | inline diff:
From aec7f6858d2117fe190f6852509c70fa6b9f6853 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Tue, 15 Sep 2026 21:59:01 +0100
Subject: [PATCH-v1 1/4] nbtree: skip insertion of existing tuples
This patch checks whether the tuple being inserted is present in the index.
A new field was added to BTInsertState struct, that field takes space previously
used by padding (assuming sizeof(bool) == 1 and sizeof(OffsetNumber) == 2).
This flag is set when searching for the insertion point in _bt_binsrch_posting
and _bt_binsrch_insert, then check at _bt_doinsert. An alternative implementation
could check the duplicate at _bt_doinsert using the insertstate offsets, without
the requiring the new, but during the search the duplicate appears naturally,
while rechecking at _bt_doinsert would require some new code, and halding
posting lists separate from regular tuples.
Removed a cmpval that made the code less readable in _bt_binsrch_insert.
---
src/backend/access/nbtree/nbtinsert.c | 11 +++++++++-
src/backend/access/nbtree/nbtsearch.c | 29 ++++++++++++++++++---------
src/include/access/nbtree.h | 1 +
3 files changed, 31 insertions(+), 10 deletions(-)
diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c
index 3b945342d83..8d3e2a6501c 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -157,6 +157,7 @@ _bt_doinsert(Relation rel, IndexTuple itup,
insertstate.itemsz = MAXALIGN(IndexTupleSize(itup));
insertstate.itup_key = itup_key;
insertstate.bounds_valid = false;
+ insertstate.is_duplicate = false;
insertstate.buf = InvalidBuffer;
insertstate.postingoff = 0;
@@ -260,7 +261,15 @@ search:
*/
newitemoff = _bt_findinsertloc(rel, &insertstate, checkingunique,
indexUnchanged, stack, heapRel);
- _bt_insertonpg(rel, heapRel, itup_key, insertstate.buf, InvalidBuffer,
+
+ if (insertstate.is_duplicate)
+ elog(DEBUG1,
+ "btree duplicate index tuple: index \"%s\" TID (%u,%u)",
+ RelationGetRelationName(rel),
+ ItemPointerGetBlockNumber(&itup->t_tid),
+ ItemPointerGetOffsetNumber(&itup->t_tid));
+ else
+ _bt_insertonpg(rel, heapRel, itup_key, insertstate.buf, InvalidBuffer,
stack, itup, insertstate.itemsz, newitemoff,
insertstate.postingoff, false);
}
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 5964bc9195e..2082a2d1a7c 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -33,7 +33,7 @@ static Buffer _bt_moveright(Relation rel, Relation heaprel, BTScanInsert key,
Buffer buf, bool forupdate, BTStack stack,
int access);
static OffsetNumber _bt_binsrch(Relation rel, BTScanInsert key, Buffer buf);
-static int _bt_binsrch_posting(BTScanInsert key, Page page,
+static int _bt_binsrch_posting(BTInsertState insertstate, Page page,
OffsetNumber offnum);
static inline void _bt_returnitem(IndexScanDesc scan, BTScanOpaque so);
static bool _bt_steppage(IndexScanDesc scan, ScanDirection dir);
@@ -482,8 +482,7 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate)
OffsetNumber low,
high,
stricthigh;
- int32 result,
- cmpval;
+ int32 result;
page = BufferGetPage(insertstate->buf);
opaque = BTPageGetOpaque(page);
@@ -491,6 +490,7 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate)
Assert(P_ISLEAF(opaque));
Assert(!key->nextkey);
Assert(insertstate->postingoff == 0);
+ insertstate->is_duplicate = false;
if (!insertstate->bounds_valid)
{
@@ -529,8 +529,6 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate)
high++; /* establish the loop invariant for high */
stricthigh = high; /* high initially strictly higher */
- cmpval = 1; /* !nextkey comparison value */
-
while (high > low)
{
OffsetNumber mid = low + ((high - low) / 2);
@@ -539,7 +537,7 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate)
result = _bt_compare(rel, key, page, mid);
- if (result >= cmpval)
+ if (result > 0)
low = mid + 1;
else
{
@@ -547,7 +545,6 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate)
if (result != 0)
stricthigh = high;
}
-
/*
* If tuple at offset located by binary search is a posting list whose
* TID range overlaps with caller's scantid, perform posting list
@@ -572,7 +569,7 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate)
BufferGetBlockNumber(insertstate->buf),
RelationGetRelationName(rel))));
- insertstate->postingoff = _bt_binsrch_posting(key, page, mid);
+ insertstate->postingoff = _bt_binsrch_posting(insertstate, page, mid);
}
}
@@ -602,8 +599,9 @@ _bt_binsrch_insert(Relation rel, BTInsertState insertstate)
*----------
*/
static int
-_bt_binsrch_posting(BTScanInsert key, Page page, OffsetNumber offnum)
+_bt_binsrch_posting(BTInsertState insertstate, Page page, OffsetNumber offnum)
{
+ BTScanInsert key = insertstate->itup_key;
IndexTuple itup;
ItemId itemid;
int low,
@@ -624,7 +622,17 @@ _bt_binsrch_posting(BTScanInsert key, Page page, OffsetNumber offnum)
itemid = PageGetItemId(page, offnum);
itup = (IndexTuple) PageGetItem(page, itemid);
if (!BTreeTupleIsPosting(itup))
+ {
+ /*
+ * Let K be the tuple (scankey, tid) being inserted.
+ * For this search there is is at least one tuple x such that
+ * L <= x <= K <= H
+ * Since itup is not a posting list, the search space shrinks
+ * to L = H, forcing x = K.
+ */
+ insertstate->is_duplicate = true;
return 0;
+ }
Assert(key->heapkeyspace && key->allequalimage);
@@ -653,7 +661,10 @@ _bt_binsrch_posting(BTScanInsert key, Page page, OffsetNumber offnum)
else if (res < 0)
high = mid;
else
+ {
+ insertstate->is_duplicate = true;
return mid;
+ }
}
/* Exact match not found */
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..db92d90bb86 100644
--- a/src/include/access/nbtree.h
+++ b/src/include/access/nbtree.h
@@ -832,6 +832,7 @@ typedef struct BTInsertStateData
* _bt_findinsertloc for details.
*/
bool bounds_valid;
+ bool is_duplicate;
OffsetNumber low;
OffsetNumber stricthigh;
--
2.53.0
[application/octet-stream] v1-0002-logging-and-testcase.patch (10.6K, ../../CAE8JnxP+Ubdj-kaxfgNt=3_FzD3GgOc3gEvPAvrXKfgTgH2q5w@mail.gmail.com/4-v1-0002-logging-and-testcase.patch)
download | inline diff:
From 77e3c1f8772f52f0f7e99801b3daaa7e770f729b Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 08:42:31 +0100
Subject: [PATCH-v1 2/4] logging and testcase
This patch adds clearer logging and a (failing) test case on regress tablespace
to be fixed by the next patch.
To facilitate the review this patch keep DEBUG1 messages in the testcase
To spot the bug keep an eie on repeated ctids, e.g.
SELECT ctid, a FROM defer_t ORDER BY a;
ctid | a
-------+---
(0,1) | 1
(0,2) | 3 <-
(0,2) | 3 <-
(0,3) | 4
---
src/backend/catalog/storage.c | 13 +++
src/backend/commands/tablecmds.c | 8 ++
src/backend/commands/tablespace.c | 8 ++
src/backend/storage/smgr/md.c | 10 +-
src/backend/utils/cache/relcache.c | 5 +
src/test/regress/sql/tablespace.sql | 156 ++++++++++++++++++++++++++++
6 files changed, 196 insertions(+), 4 deletions(-)
diff --git a/src/backend/catalog/storage.c b/src/backend/catalog/storage.c
index e443a4993c5..db4653b27e8 100644
--- a/src/backend/catalog/storage.c
+++ b/src/backend/catalog/storage.c
@@ -26,6 +26,7 @@
#include "access/xlogutils.h"
#include "catalog/storage.h"
#include "catalog/storage_xlog.h"
+#include "common/relpath.h"
#include "miscadmin.h"
#include "pgstat.h"
#include "storage/bulk_write.h"
@@ -150,6 +151,9 @@ RelationCreateStorage(RelFileLocator rlocator, char relpersistence,
srel = smgropen(rlocator, procNumber);
smgrcreate(srel, MAIN_FORKNUM, false);
+ elog(DEBUG1, "RelationCreateStorage: %s",
+ relpathperm(rlocator, MAIN_FORKNUM).str);
+
if (needs_wal)
log_smgrcreate(&srel->smgr_rlocator.locator, MAIN_FORKNUM);
@@ -208,6 +212,10 @@ RelationDropStorage(Relation rel)
{
PendingRelDelete *pending;
+ elog(DEBUG1, "RelationDropStorage: oid %u %s",
+ RelationGetRelid(rel),
+ relpathperm(rel->rd_locator, MAIN_FORKNUM).str);
+
/* Add the relation to the list of stuff to delete at commit */
pending = (PendingRelDelete *)
MemoryContextAlloc(TopMemoryContext, sizeof(PendingRelDelete));
@@ -701,6 +709,11 @@ smgrDoPendingDeletes(bool isCommit)
{
SMgrRelation srel;
+ elog(DEBUG1,
+ "smgrDoPendingDeletes: %s %s",
+ isCommit ? "commit" : "abort",
+ relpathperm(pending->rlocator, MAIN_FORKNUM).str);
+
srel = smgropen(pending->rlocator, pending->procNumber);
/* allocate the initial array, or extend it, if needed */
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 2f073ddb84a..1e6d0b1611f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -69,6 +69,7 @@
#include "commands/user.h"
#include "commands/vacuum.h"
#include "common/int.h"
+#include "common/relpath.h"
#include "executor/executor.h"
#include "foreign/fdwapi.h"
#include "foreign/foreign.h"
@@ -17560,6 +17561,13 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
newrlocator.relNumber = newrelfilenumber;
newrlocator.spcOid = newTableSpace;
+ elog(DEBUG1,
+ "SET TABLESPACE copy: rel \"%s\" oid %u, from %s to %s",
+ RelationGetRelationName(rel),
+ RelationGetRelid(rel),
+ relpathperm(rel->rd_locator, MAIN_FORKNUM).str,
+ relpathperm(newrlocator, MAIN_FORKNUM).str);
+
/* hand off to AM to actually create new rel storage and copy the data */
if (rel->rd_rel->relkind == RELKIND_INDEX)
{
diff --git a/src/backend/commands/tablespace.c b/src/backend/commands/tablespace.c
index e01fb2db913..bba656e9b78 100644
--- a/src/backend/commands/tablespace.c
+++ b/src/backend/commands/tablespace.c
@@ -176,6 +176,8 @@ TablespaceCreateDbspace(Oid spcOid, Oid dbOid, bool isRedo)
errmsg("could not create directory \"%s\": %m",
dir)));
}
+
+ elog(DEBUG1, "TablespaceCreateDbspace: %s", dir);
}
LWLockRelease(TablespaceCreateLock);
@@ -677,6 +679,12 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid)
errmsg("could not create symbolic link \"%s\": %m",
linkloc)));
+ if (in_place)
+ elog(DEBUG1, "tablespace directories: %s", linkloc);
+ else
+ elog(DEBUG1, "tablespace directories: %s -> %s",
+ linkloc, location_with_version_dir);
+
pfree(linkloc);
pfree(location_with_version_dir);
}
diff --git a/src/backend/storage/smgr/md.c b/src/backend/storage/smgr/md.c
index 780c88c0630..f5906afa17b 100644
--- a/src/backend/storage/smgr/md.c
+++ b/src/backend/storage/smgr/md.c
@@ -245,6 +245,8 @@ mdcreate(SMgrRelation reln, ForkNumber forknum, bool isRedo)
path = relpath(reln->smgr_rlocator, forknum);
+ elog(DEBUG1, "mdcreate: %s", path.str);
+
fd = PathNameOpenFile(path.str, _mdfd_open_flags() | O_CREAT | O_EXCL);
if (fd < 0)
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 541f30f0972..46e5501dd7e 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -61,6 +61,7 @@
#include "catalog/pg_statistic_ext.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_tablespace.h"
+#include "common/relpath.h"
#include "catalog/pg_trigger.h"
#include "catalog/pg_type.h"
#include "catalog/schemapg.h"
@@ -3982,6 +3983,10 @@ RelationAssumeNewRelfilelocator(Relation relation)
if (relation->rd_firstRelfilelocatorSubid == InvalidSubTransactionId)
relation->rd_firstRelfilelocatorSubid = relation->rd_newRelfilelocatorSubid;
+ elog(DEBUG1, "RelationAssumeNewRelfilelocator: oid %u %s",
+ RelationGetRelid(relation),
+ relpathperm(relation->rd_locator, MAIN_FORKNUM).str);
+
/* Flag relation as needing eoxact cleanup (to clear these fields) */
EOXactListAdd(relation);
}
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..52ebf9e3b6b 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -437,6 +437,162 @@ name";
-- Should succeed
DROP TABLESPACE regress_tblspace_renamed;
+--
+-- Deferred heap copy for ALTER TABLE SET TABLESPACE on indexed tables
+--
+SET allow_in_place_tablespaces = true;
+SET client_min_messages = DEBUG1;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+CREATE TABLESPACE regress_tblspace LOCATION '';
+CREATE TABLESPACE regress_tblspace2 LOCATION '';
+
+-- COMMIT
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+COMMIT;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- ROLLBACK
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+ROLLBACK;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- PREPARE TRANSACTION, COMMIT PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_commit';
+COMMIT PREPARED 'defer_tblsp_prep_commit';
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- PREPARE TRANSACTION, ROLLBACK PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_rollback';
+ROLLBACK PREPARED 'defer_tblsp_prep_rollback';
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: move in savepoint, subcommit, then top-level commit
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+INSERT INTO defer_t VALUES (6), (7);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: move in savepoint, then rollback to savepoint
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+ROLLBACK TO SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: subcommit move, then top-level rollback cancels move
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+ROLLBACK;
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: two deferred moves in one transaction
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (6);
+COMMIT;
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (6);
+ROLLBACK;
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+RESET enable_sort;
+
+DROP TABLESPACE regress_tblspace;
+DROP TABLESPACE regress_tblspace2;
+
DROP SCHEMA testschema CASCADE;
DROP ROLE regress_tablespace_user1;
--
2.53.0
[application/octet-stream] v1-0004-establish-expected-tablespace.out.patch (7.0K, ../../CAE8JnxP+Ubdj-kaxfgNt=3_FzD3GgOc3gEvPAvrXKfgTgH2q5w@mail.gmail.com/5-v1-0004-establish-expected-tablespace.out.patch)
download | inline diff:
From 9a14e0082a69a4519090faa1a09e778b5ae0a92c Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 20:18:05 +0100
Subject: [PATCH-v1 4/4] establish expected/tablespace.out
wrap up with the regress reference and disable debugging
---
src/test/regress/expected/tablespace.out | 226 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 1 -
2 files changed, 226 insertions(+), 1 deletion(-)
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..f4f073b3d9b 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -967,6 +967,232 @@ ERROR: tablespace name "invalid
name" contains a newline or carriage return character
-- Should succeed
DROP TABLESPACE regress_tblspace_renamed;
+--
+-- Deferred heap copy for ALTER TABLE SET TABLESPACE on indexed tables
+--
+SET allow_in_place_tablespaces = true;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+CREATE TABLESPACE regress_tblspace LOCATION '';
+CREATE TABLESPACE regress_tblspace2 LOCATION '';
+-- COMMIT
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+COMMIT;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+(5 rows)
+
+DROP TABLE defer_t;
+-- ROLLBACK
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+ROLLBACK;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,4) | 4
+ (0,5) | 5
+(3 rows)
+
+DROP TABLE defer_t;
+-- PREPARE TRANSACTION, COMMIT PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_commit';
+COMMIT PREPARED 'defer_tblsp_prep_commit';
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+(5 rows)
+
+DROP TABLE defer_t;
+-- PREPARE TRANSACTION, ROLLBACK PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_rollback';
+ROLLBACK PREPARED 'defer_tblsp_prep_rollback';
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,4) | 4
+ (0,5) | 5
+(3 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: move in savepoint, subcommit, then top-level commit
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+INSERT INTO defer_t VALUES (6), (7);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+ (0,6) | 6
+ (0,7) | 7
+(7 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: move in savepoint, then rollback to savepoint
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+ROLLBACK TO SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,5) | 5
+ (0,6) | 6
+ (0,7) | 7
+ (0,8) | 8
+(6 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: subcommit move, then top-level rollback cancels move
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+ROLLBACK;
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,6) | 6
+ (0,7) | 7
+ (0,8) | 8
+(4 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: two deferred moves in one transaction
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (6);
+COMMIT;
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+ (0,6) | 6
+ (0,7) | 7
+ (0,8) | 8
+ (0,9) | 9
+(9 rows)
+
+DROP TABLE defer_t;
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (6);
+ROLLBACK;
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,7) | 7
+ (0,8) | 8
+ (0,9) | 9
+(4 rows)
+
+DROP TABLE defer_t;
+RESET enable_sort;
+DROP TABLESPACE regress_tblspace;
+DROP TABLESPACE regress_tblspace2;
DROP SCHEMA testschema CASCADE;
NOTICE: drop cascades to 7 other objects
DETAIL: drop cascades to table testschema.foo
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 52ebf9e3b6b..b7df6923303 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -441,7 +441,6 @@ DROP TABLESPACE regress_tblspace_renamed;
-- Deferred heap copy for ALTER TABLE SET TABLESPACE on indexed tables
--
SET allow_in_place_tablespaces = true;
-SET client_min_messages = DEBUG1;
SET enable_seqscan = off;
SET enable_bitmapscan = off;
CREATE TABLESPACE regress_tblspace LOCATION '';
--
2.53.0
[application/octet-stream] v1-0003-fix-deferred-relation-copy.patch (9.3K, ../../CAE8JnxP+Ubdj-kaxfgNt=3_FzD3GgOc3gEvPAvrXKfgTgH2q5w@mail.gmail.com/6-v1-0003-fix-deferred-relation-copy.patch)
download | inline diff:
From 602ef579d785bc97fd581b870a6e9a7f46e38202 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 19:23:08 +0100
Subject: [PATCH-v1 3/4] fix: deferred relation copy
When running ALTER TABLE SET TABLESPACE on a table externally
referenced by TID, e.g. having indices, outside the transaction, defer
the copy of the relation fields to the commit, in order to keep the
index consistent with the heap outside the transaction.
---
src/backend/access/transam/xact.c | 14 +++
src/backend/commands/tablecmds.c | 159 +++++++++++++++++++++++++++++-
src/include/commands/tablecmds.h | 5 +
3 files changed, 174 insertions(+), 4 deletions(-)
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index aca92507ebd..d41cf68e48f 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -2351,6 +2351,12 @@ CommitTransaction(void)
*/
PreCommit_on_commit_actions();
+ /*
+ * Perform the physical copy of relation files that were deferred
+ * during execution of SET TABLESPACE commands.
+ */
+ PreCommit_deferred_tablespace_moves();
+
/*
* Synchronize files that are created and not WAL-logged during this
* transaction. This must happen before AtEOXact_RelationMap(), so that we
@@ -2508,6 +2514,7 @@ CommitTransaction(void)
AtEOXact_SPI(true);
AtEOXact_Enum();
AtEOXact_on_commit_actions(true);
+ AtEOXact_deferred_tablespace_moves(true);
AtEOXact_Namespace(true, is_parallel_worker);
AtEOXact_SMgr();
AtEOXact_Files(true);
@@ -2613,6 +2620,7 @@ PrepareTransaction(void)
* cursors, to avoid dangling-reference problems)
*/
PreCommit_on_commit_actions();
+ PreCommit_deferred_tablespace_moves();
/*
* Synchronize files that are created and not WAL-logged during this
@@ -2804,6 +2812,7 @@ PrepareTransaction(void)
AtEOXact_SPI(true);
AtEOXact_Enum();
AtEOXact_on_commit_actions(true);
+ AtEOXact_deferred_tablespace_moves(true);
AtEOXact_Namespace(true, false);
AtEOXact_SMgr();
AtEOXact_Files(true);
@@ -3035,6 +3044,7 @@ AbortTransaction(void)
AtEOXact_SPI(false);
AtEOXact_Enum();
AtEOXact_on_commit_actions(false);
+ AtEOXact_deferred_tablespace_moves(false);
AtEOXact_Namespace(false, is_parallel_worker);
AtEOXact_SMgr();
AtEOXact_Files(false);
@@ -5238,6 +5248,8 @@ CommitSubTransaction(void)
AtEOSubXact_SPI(true, s->subTransactionId);
AtEOSubXact_on_commit_actions(true, s->subTransactionId,
s->parent->subTransactionId);
+ AtEOSubXact_deferred_tablespace_moves(true, s->subTransactionId,
+ s->parent->subTransactionId);
AtEOSubXact_Namespace(true, s->subTransactionId,
s->parent->subTransactionId);
AtEOSubXact_Files(true, s->subTransactionId,
@@ -5413,6 +5425,8 @@ AbortSubTransaction(void)
AtEOSubXact_SPI(false, s->subTransactionId);
AtEOSubXact_on_commit_actions(false, s->subTransactionId,
s->parent->subTransactionId);
+ AtEOSubXact_deferred_tablespace_moves(false, s->subTransactionId,
+ s->parent->subTransactionId);
AtEOSubXact_Namespace(false, s->subTransactionId,
s->parent->subTransactionId);
AtEOSubXact_Files(false, s->subTransactionId,
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 1e6d0b1611f..0a8bd9cc9ab 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -133,6 +133,16 @@ typedef struct OnCommitItem
static List *on_commits = NIL;
+typedef struct PendingTablespaceMove
+{
+ Oid relid;
+ Oid newTableSpace;
+ SubTransactionId creating_subid;
+} PendingTablespaceMove;
+
+static List *deferred_tablespace_moves = NIL;
+
+
/*
* State information for ALTER TABLE
@@ -695,6 +705,9 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
+static bool ATRelationIsFreeToMove(Relation rel);
+static void ATExecSetTableSpaceCopy(Oid tableOid, Oid newTableSpace,
+ LOCKMODE lockmode);
static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
@@ -17516,7 +17529,7 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpaceCopy(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
{
Relation rel;
Oid reltoastrelid;
@@ -17608,6 +17621,77 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
list_free(reltoastidxids);
}
+
+/*
+ * ATRelationIsFreeToMove
+ *
+ * Determines whether a relation file can be copied immediately or it has to
+ * wait until the transaction commit.
+ *
+ * When the relation has indices in the old tablespace because we have to
+ * keep also invisible rows produced after the tablespace update must remain
+ * consistent in the case of a rollback.
+ */
+static bool
+ATRelationIsFreeToMove(Relation rel)
+{
+ char relkind = rel->rd_rel->relkind;
+
+ if (relkind != RELKIND_RELATION && relkind != RELKIND_MATVIEW)
+ return true;
+
+ return RelationGetIndexList(rel) == NIL;
+}
+
+/*
+ * ATExecSetTableSpace
+ *
+ * Either copy the relation files, or push the an item to the list of
+ * deferred tablespace moves, that are handled at commit.
+ */
+static void
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+{
+ Relation rel;
+ PendingTablespaceMove *pending;
+
+ rel = relation_open(tableOid, lockmode);
+
+ if (!CheckRelationTableSpaceMove(rel, newTableSpace))
+ {
+ InvokeObjectPostAlterHook(RelationRelationId,
+ RelationGetRelid(rel), 0);
+ relation_close(rel, NoLock);
+ return;
+ }
+
+ if (ATRelationIsFreeToMove(rel))
+ {
+ relation_close(rel, NoLock);
+ ATExecSetTableSpaceCopy(tableOid, newTableSpace, lockmode);
+ }
+ else
+ {
+ MemoryContext oldcxt;
+
+ /*
+ * Keep both the list cells and entries in TopTransactionContext for
+ * the whole transaction, like on_commits uses CacheMemoryContext.
+ */
+ oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+ pending = MemoryContextAlloc(TopTransactionContext,
+ sizeof(PendingTablespaceMove));
+ pending->relid = tableOid;
+ pending->newTableSpace = newTableSpace;
+ pending->creating_subid = GetCurrentSubTransactionId();
+ deferred_tablespace_moves = lappend(deferred_tablespace_moves, pending);
+
+ MemoryContextSwitchTo(oldcxt);
+
+ relation_close(rel, NoLock);
+ }
+}
+
/*
* Special handling of ALTER TABLE SET TABLESPACE for relations with no
* storage that have an interest in preserving tablespace.
@@ -20113,6 +20197,72 @@ PreCommit_on_commit_actions(void)
}
}
+/*
+ * Perform the file copies that might have been deferred during the
+ * transaction.
+ * See also: ATRelationIsFreeToMove
+ */
+void
+PreCommit_deferred_tablespace_moves(void)
+{
+ ListCell *lc;
+
+ foreach(lc, deferred_tablespace_moves)
+ {
+ PendingTablespaceMove *pending = (PendingTablespaceMove *) lfirst(lc);
+
+ ATExecSetTableSpaceCopy(pending->relid, pending->newTableSpace,
+ AccessExclusiveLock);
+ }
+
+ deferred_tablespace_moves = NIL;
+}
+
+/*
+ * Propagate deferred tablespace moves from sub transactions.
+ * The move should be performed only at the commit of the top level
+ * transaction.
+ */
+void
+AtEOSubXact_deferred_tablespace_moves(bool isCommit, SubTransactionId mySubid,
+ SubTransactionId parentSubid)
+{
+ ListCell *cur_item;
+
+ foreach(cur_item, deferred_tablespace_moves)
+ {
+ PendingTablespaceMove *pending = (PendingTablespaceMove *) lfirst(cur_item);
+ if (pending->creating_subid != mySubid)
+ continue;
+
+ if (isCommit)
+ {
+ pending->creating_subid = parentSubid;
+ continue;
+ }
+
+ deferred_tablespace_moves = foreach_delete_current(deferred_tablespace_moves,
+ cur_item);
+ pfree(pending);
+ }
+}
+/*
+ * Clean up deferred tablespace moves list at top level transaction
+ */
+void
+AtEOXact_deferred_tablespace_moves(bool isCommit)
+{
+ if (!isCommit)
+ {
+ /*
+ * TopTransactionContext is about to be reset, so just drop the
+ * pointer. Do not pfree entries here.
+ */
+ deferred_tablespace_moves = NIL;
+ }
+}
+
+
/*
* Post-commit or post-abort cleanup for ON COMMIT management.
*
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index c3d8518cb62..c964d0c7bf7 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -93,8 +93,13 @@ extern void check_of_type(HeapTuple typetuple);
extern void register_on_commit_action(Oid relid, OnCommitAction action);
extern void remove_on_commit_action(Oid relid);
+extern void PreCommit_deferred_tablespace_moves(void);
extern void PreCommit_on_commit_actions(void);
+extern void AtEOXact_deferred_tablespace_moves(bool isCommit);
extern void AtEOXact_on_commit_actions(bool isCommit);
+extern void AtEOSubXact_deferred_tablespace_moves(bool isCommit,
+ SubTransactionId mySubid,
+ SubTransactionId parentSubid);
extern void AtEOSubXact_on_commit_actions(bool isCommit,
SubTransactionId mySubid,
SubTransactionId parentSubid);
--
2.53.0
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-27 18:56 Alexandre Felipe <o.alexandre.felipe@gmail.com>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
1 sibling, 1 reply; 33+ messages in thread
From: Alexandre Felipe @ 2026-09-27 18:56 UTC (permalink / raw)
To: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>; Alexander Lakhin <exclusion@gmail.com>; manuelreyesbravo@gmail.com
Hi Manu,
What do you think of this approach?
Points that I know require some attention are:
1. CFBot windows test is failing.
2. Failure to copy the files at the end of transaction, e.g. the new
tablespace doesn't have enough space. Will the transaction roll back
cleanly reclaiming the space?
I had a quick look in your patch, that approach is simpler, but if I
understand
correctly ATExecSetTableSpaceNewIndexRelfilenumber copies the index
upfront while upstream doesn't copy the indexes at all.
It is not difficult to imagine a scenario where this is undesirable. For
instance,
if the user is moving the table because the original tablespace is full,
creating an additional copy of the index might make the problem worse.
My proposal was to do the other way, push the cost to the end of the
transaction instead, even the heap copy, so if the transaction fails it will
do no file copies. If the transaction completes, it will do exactly the same
copies that upstream does, just in a different order. At least that is the
rationale, maybe during review additional cases that I missed will come up.
Regards,
Alexandre
On Wed, Sep 16, 2026 at 11:06 PM Alexandre Felipe <
o.alexandre.felipe@gmail.com> wrote:
> This patchset addresses the issue reported on the pgsql-bugs [1]
>
> The root cause is that after the relation files are copied to a new
> tablespace queries
> update the index in place but the heap is updated only in the tablespace
> copy. If
> the transaction is rolled back, the index and the heap becomes
> inconsistent.
>
> First I tried to fix the crash, easy for btree, manageable for hash, but
> for GiST that
> would not be feasible, AFAIK would have to perform array searches possibly
> over
> multiple pages. Later thinking about this I noticed something I didn't
> realise on my
> first read.
>
> Even without inserting duplicates, and no crashes, it can produce
> incorrect results.
>
> SET enable_seqscan = off;
> SET enable_bitmapscan = off;
> SET allow_in_place_tablespaces = true;
> CREATE TABLESPACE ts LOCATION '';
> CREATE TABLE t(a int);
> CREATE INDEX ON t(a);
> BEGIN;
> ALTER TABLE t SET TABLESPACE ts;
> INSERT INTO t VALUES (0); -- this adds (0, 1) | 0 to the index in the ts
> copy
> ROLLBACK;
> INSERT INTO t VALUES (41); -- this adds (0, 1) | 41 in the default
> tablespace
> SELECT ctid, a FROM t WHERE a = 0;
> ctid | a
> -------+----
> (0,1) | 41
> (1 row)
>
> So, I decided to fix the root cause: modifying a non-durable copy of the
> file.
> I thought it would be way harder, but the code was architected well enough
> that I could save a list of deferred copies, and keep modifying the the
> table
> in place. If the transaction is rolled back all the tuples in the index
> will have
> its (possibly dead) in the heap, effectively reserving those TID, this
> prevents
> both the insertion of duplicates, and the resuscitation of dead tuples by
> later changes.
>
>
>
> [1]
> https://www.postgresql.org/message-id/19686-30f4da834585129f%40postgresql.org
>
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-27 19:09 Manu <manuelreyesbravo@gmail.com>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Manu @ 2026-09-27 19:09 UTC (permalink / raw)
To: Alexandre Felipe <o.alexandre.felipe@gmail.com>; pgsql-hackers@lists.postgresql.org; +Cc: Alexander Lakhin <exclusion@gmail.com>; Shihao Zhong <zhong950419@gmail.com>
I wrote a patch for the same bug before I saw this thread -- thanks to
Shihao for pointing me here. Having read #7312, I think your deferred
copy is the right approach, and better than what I had: it fixes the
root cause without touching the indexes.
> What do you think of this approach?
On performance it looks very good. On a 1M-row table with three indexes
I measured SET TABLESPACE at about 104 ms with #7312 versus about 108 ms
on master -- no measurable overhead, since the indexes are left alone.
(My own patch copies each index to a new relfilenode, which came out
around 1.7x, so #7312 is the better direction and I'm happy to drop mine.)
One thing I ran into while testing: a second SET TABLESPACE in the same
transaction, on a table with indexes, ends up in the wrong tablespace.
SET allow_in_place_tablespaces = true;
CREATE TABLESPACE ts LOCATION '';
CREATE TABLE t (a int);
CREATE INDEX ON t (a);
BEGIN;
ALTER TABLE t SET TABLESPACE ts;
ALTER TABLE t SET TABLESPACE pg_default;
COMMIT;
-- master: t ends up in pg_default; with #7312: t ends up in ts
The second ALTER calls CheckRelationTableSpaceMove() while pg_class still
shows the original tablespace (the first move is deferred), so moving
back to pg_default looks like a no-op and is dropped. The deferred move
probably needs to be visible to a later SET TABLESPACE in the same
transaction.
> 2. Failure to copy the files at the end of transaction [...]
Agreed this one deserves care -- moving the copy into PreCommit means a
full tablespace now fails at commit time rather than during the command.
On 0001: with 0003 in place I couldn't get the new "skip existing tuple"
path to fire in any of my tests, and turning the assertion into a silent
skip also drops a useful corruption check. Could it be removed now?
Happy to help test further.
Manu
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-29 08:37 Alexandre Felipe <o.alexandre.felipe@gmail.com>
parent: Manu <manuelreyesbravo@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Alexandre Felipe @ 2026-09-29 08:37 UTC (permalink / raw)
To: Manu <manuelreyesbravo@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, Alexander Lakhin <exclusion@gmail.com>; Shihao Zhong <zhong950419@gmail.com>
Hi Manu,
On Sun, Sep 27, 2026 at 8:09 PM Manu <manuelreyesbravo@gmail.com> wrote:
> One thing I ran into while testing: a second SET TABLESPACE in the same
> transaction, on a table with indexes, ends up in the wrong tablespace.
>
> SET allow_in_place_tablespaces = true;
> CREATE TABLESPACE ts LOCATION '';
> CREATE TABLE t (a int);
> CREATE INDEX ON t (a);
> BEGIN;
> ALTER TABLE t SET TABLESPACE ts;
> ALTER TABLE t SET TABLESPACE pg_default;
> COMMIT;
> -- master: t ends up in pg_default; with #7312: t ends up in ts
>
Noted
> The second ALTER calls CheckRelationTableSpaceMove() while pg_class still
> shows the original tablespace (the first move is deferred), so moving
> back to pg_default looks like a no-op and is dropped. The deferred move
> probably needs to be visible to a later SET TABLESPACE in the same
> transaction.
>
Visible to a later tablespace but not to other operations that might check
the tablespace to find the relation. I just noticed that if someone check
the pg_tablespaces inside the transaction they will get unexpected results.
(Is that something we need to fix?)
For v2 I am not using CheckRelationTableSpaceMove as a filter
for deferred copies, only for physical copies.
PreCommit_deferred_tablespace_moves is ignoring all intermediate
moves.
Added final tablespace verification to the test.
Output with debugging shows
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+DEBUG: ATExecSetTableSpace: rel 16793 to tblspace 16782 (deferred copy)
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE pg_default;
+DEBUG: ATExecSetTableSpace: rel 16793 to tblspace 1663 (deferred copy)
+INSERT INTO defer_t VALUES (4);
+COMMIT;
+EXECUTE check_tablespace;
+nspname | relname | tablespace
+---------+---------+------------
+ public | defer_t | (default)
On 0001: with 0003 in place I couldn't get the new "skip existing tuple"
> path to fire in any of my tests, and turning the assertion into a silent
> skip also drops a useful corruption check. Could it be removed now
Agreed, removed from v2.
Regards,
Alexandre
Attachments:
[application/octet-stream] v2-0003-establish-expected-tablespace.out.patch (9.4K, ../../CAE8JnxNZCPQOXHRRYtyUhWxLB=GNKEAkUEQ+7WjBLH17Jd+xnQ@mail.gmail.com/3-v2-0003-establish-expected-tablespace.out.patch)
download | inline diff:
From 0357041307fcdb847af404538dac53087b4897d5 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 20:18:05 +0100
Subject: [PATCH-v2 3/3] establish expected/tablespace.out
wrap up with the regress reference and disable debugging
---
src/test/regress/expected/tablespace.out | 322 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 1 -
2 files changed, 322 insertions(+), 1 deletion(-)
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..4d0a0fa8906 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -967,6 +967,328 @@ ERROR: tablespace name "invalid
name" contains a newline or carriage return character
-- Should succeed
DROP TABLESPACE regress_tblspace_renamed;
+--
+-- Deferred heap copy for ALTER TABLE SET TABLESPACE on indexed tables
+--
+SET allow_in_place_tablespaces = true;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+CREATE TABLESPACE regress_tblspace LOCATION '';
+CREATE TABLESPACE regress_tblspace2 LOCATION '';
+PREPARE check_tablespace AS
+ SELECT n.nspname, c.relname, coalesce(s.spcname, '(default)') AS tablespace
+ FROM pg_class c
+ LEFT JOIN pg_tablespace s ON s.oid = c.reltablespace
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE c.relname = 'defer_t'
+ ORDER BY 1;
+-- COMMIT
+CREATE TABLE defer_t (a int);
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------
+ public | defer_t | (default)
+(1 row)
+
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+COMMIT;
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------------
+ public | defer_t | regress_tblspace
+(1 row)
+
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+(5 rows)
+
+DROP TABLE defer_t;
+-- ROLLBACK
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+ROLLBACK;
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------
+ public | defer_t | (default)
+(1 row)
+
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,4) | 4
+ (0,5) | 5
+(3 rows)
+
+DROP TABLE defer_t;
+-- COMMIT ENDING UP IN THE SAME SPACE
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE pg_default;
+INSERT INTO defer_t VALUES (4);
+COMMIT;
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------
+ public | defer_t | (default)
+(1 row)
+
+INSERT INTO defer_t VALUES (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+(5 rows)
+
+DROP TABLE defer_t;
+-- PREPARE TRANSACTION, COMMIT PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_commit';
+COMMIT PREPARED 'defer_tblsp_prep_commit';
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------------
+ public | defer_t | regress_tblspace
+(1 row)
+
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+(5 rows)
+
+DROP TABLE defer_t;
+-- PREPARE TRANSACTION, ROLLBACK PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_rollback';
+ROLLBACK PREPARED 'defer_tblsp_prep_rollback';
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------
+ public | defer_t | (default)
+(1 row)
+
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,4) | 4
+ (0,5) | 5
+(3 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: move in savepoint, subcommit, then top-level commit
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------------
+ public | defer_t | regress_tblspace
+(1 row)
+
+INSERT INTO defer_t VALUES (6), (7);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+ (0,6) | 6
+ (0,7) | 7
+(7 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: move in savepoint, then rollback to savepoint
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+ROLLBACK TO SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------
+ public | defer_t | (default)
+(1 row)
+
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,5) | 5
+ (0,6) | 6
+ (0,7) | 7
+ (0,8) | 8
+(6 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: subcommit move, then top-level rollback cancels move
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+ROLLBACK;
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------
+ public | defer_t | (default)
+(1 row)
+
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,6) | 6
+ (0,7) | 7
+ (0,8) | 8
+(4 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: two deferred moves in one transaction
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (6);
+COMMIT;
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+-------------------
+ public | defer_t | regress_tblspace2
+(1 row)
+
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+ (0,6) | 6
+ (0,7) | 7
+ (0,8) | 8
+ (0,9) | 9
+(9 rows)
+
+DROP TABLE defer_t;
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (6);
+ROLLBACK;
+EXECUTE check_tablespace;
+ nspname | relname | tablespace
+---------+---------+------------
+ public | defer_t | (default)
+(1 row)
+
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,7) | 7
+ (0,8) | 8
+ (0,9) | 9
+(4 rows)
+
+DROP TABLE defer_t;
+RESET enable_sort;
+DROP TABLESPACE regress_tblspace;
+DROP TABLESPACE regress_tblspace2;
DROP SCHEMA testschema CASCADE;
NOTICE: drop cascades to 7 other objects
DETAIL: drop cascades to table testschema.foo
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index d58cd4af3d5..060b8a4a46d 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -441,7 +441,6 @@ DROP TABLESPACE regress_tblspace_renamed;
-- Deferred heap copy for ALTER TABLE SET TABLESPACE on indexed tables
--
SET allow_in_place_tablespaces = true;
-SET client_min_messages = DEBUG1;
SET enable_seqscan = off;
SET enable_bitmapscan = off;
CREATE TABLESPACE regress_tblspace LOCATION '';
--
2.53.0
[application/octet-stream] v2-0002-fix-deferred-relation-copy.patch (10.4K, ../../CAE8JnxNZCPQOXHRRYtyUhWxLB=GNKEAkUEQ+7WjBLH17Jd+xnQ@mail.gmail.com/4-v2-0002-fix-deferred-relation-copy.patch)
download | inline diff:
From 67f74e98b656261af329d36f628fbdfbf7283199 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 19:23:08 +0100
Subject: [PATCH-v2 2/3] fix: deferred relation copy
When running ALTER TABLE SET TABLESPACE on a table externally
referenced by TID, e.g. having indices, outside the transaction, defer
the copy of the relation fields to the commit, in order to keep the
index consistent with the heap outside the transaction.
---
src/backend/access/transam/xact.c | 14 +++
src/backend/commands/tablecmds.c | 181 +++++++++++++++++++++++++++++-
src/include/commands/tablecmds.h | 5 +
3 files changed, 199 insertions(+), 1 deletion(-)
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index ebb010853cf..e76d72851fb 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -2351,6 +2351,12 @@ CommitTransaction(void)
*/
PreCommit_on_commit_actions();
+ /*
+ * Perform the physical copy of relation files that were deferred during
+ * execution of SET TABLESPACE commands.
+ */
+ PreCommit_deferred_tablespace_moves();
+
/*
* Synchronize files that are created and not WAL-logged during this
* transaction. This must happen before AtEOXact_RelationMap(), so that we
@@ -2508,6 +2514,7 @@ CommitTransaction(void)
AtEOXact_SPI(true);
AtEOXact_Enum();
AtEOXact_on_commit_actions(true);
+ AtEOXact_deferred_tablespace_moves(true);
AtEOXact_Namespace(true, is_parallel_worker);
AtEOXact_SMgr();
AtEOXact_Files(true);
@@ -2613,6 +2620,7 @@ PrepareTransaction(void)
* cursors, to avoid dangling-reference problems)
*/
PreCommit_on_commit_actions();
+ PreCommit_deferred_tablespace_moves();
/*
* Synchronize files that are created and not WAL-logged during this
@@ -2804,6 +2812,7 @@ PrepareTransaction(void)
AtEOXact_SPI(true);
AtEOXact_Enum();
AtEOXact_on_commit_actions(true);
+ AtEOXact_deferred_tablespace_moves(true);
AtEOXact_Namespace(true, false);
AtEOXact_SMgr();
AtEOXact_Files(true);
@@ -3035,6 +3044,7 @@ AbortTransaction(void)
AtEOXact_SPI(false);
AtEOXact_Enum();
AtEOXact_on_commit_actions(false);
+ AtEOXact_deferred_tablespace_moves(false);
AtEOXact_Namespace(false, is_parallel_worker);
AtEOXact_SMgr();
AtEOXact_Files(false);
@@ -5240,6 +5250,8 @@ CommitSubTransaction(void)
AtEOSubXact_SPI(true, s->subTransactionId);
AtEOSubXact_on_commit_actions(true, s->subTransactionId,
s->parent->subTransactionId);
+ AtEOSubXact_deferred_tablespace_moves(true, s->subTransactionId,
+ s->parent->subTransactionId);
AtEOSubXact_Namespace(true, s->subTransactionId,
s->parent->subTransactionId);
AtEOSubXact_Files(true, s->subTransactionId,
@@ -5415,6 +5427,8 @@ AbortSubTransaction(void)
AtEOSubXact_SPI(false, s->subTransactionId);
AtEOSubXact_on_commit_actions(false, s->subTransactionId,
s->parent->subTransactionId);
+ AtEOSubXact_deferred_tablespace_moves(false, s->subTransactionId,
+ s->parent->subTransactionId);
AtEOSubXact_Namespace(false, s->subTransactionId,
s->parent->subTransactionId);
AtEOSubXact_Files(false, s->subTransactionId,
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 1e6d0b1611f..1b9533230c1 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -133,6 +133,16 @@ typedef struct OnCommitItem
static List *on_commits = NIL;
+typedef struct PendingTablespaceMove
+{
+ Oid relid;
+ Oid newTableSpace;
+ SubTransactionId creating_subid;
+} PendingTablespaceMove;
+
+static List *deferred_tablespace_moves = NIL;
+
+
/*
* State information for ALTER TABLE
@@ -695,6 +705,9 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
+static bool ATRelationIsFreeToMove(Relation rel);
+static void ATExecSetTableSpaceCopy(Oid tableOid, Oid newTableSpace,
+ LOCKMODE lockmode);
static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
@@ -17516,7 +17529,7 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpaceCopy(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
{
Relation rel;
Oid reltoastrelid;
@@ -17539,6 +17552,9 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
return;
}
+ elog(DEBUG1, "ATExecSetTableSpaceCopy: rel %d to tblspace %d (copying)",
+ tableOid, newTableSpace
+ );
reltoastrelid = rel->rd_rel->reltoastrelid;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
@@ -17608,6 +17624,75 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
list_free(reltoastidxids);
}
+
+/*
+ * ATRelationIsFreeToMove
+ *
+ * Determines whether a relation file can be copied immediately or it has to
+ * wait until the transaction commit.
+ *
+ * When the relation has indices in the old tablespace because we have to
+ * keep also invisible rows produced after the tablespace update must remain
+ * consistent in the case of a rollback.
+ */
+static bool
+ATRelationIsFreeToMove(Relation rel)
+{
+ char relkind = rel->rd_rel->relkind;
+
+ if (relkind != RELKIND_RELATION && relkind != RELKIND_MATVIEW)
+ return true;
+
+ return RelationGetIndexList(rel) == NIL;
+}
+
+/*
+ * ATExecSetTableSpace
+ *
+ * Either copy the relation files, or push the an item to the list of
+ * deferred tablespace moves, that are handled at commit.
+ */
+static void
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+{
+ Relation rel;
+ PendingTablespaceMove *pending;
+
+ rel = relation_open(tableOid, lockmode);
+
+ if (ATRelationIsFreeToMove(rel))
+ {
+ relation_close(rel, NoLock);
+ ATExecSetTableSpaceCopy(tableOid, newTableSpace, lockmode);
+ }
+ else
+ {
+ MemoryContext oldcxt;
+
+ CheckRelationTableSpaceMove(rel, newTableSpace);
+
+ elog(DEBUG1, "ATExecSetTableSpace: rel %d to tblspace %d (deferred copy)",
+ tableOid, newTableSpace
+ );
+
+ /*
+ * Keep both the list cells and entries in TopTransactionContext for
+ * the whole transaction, like on_commits uses CacheMemoryContext.
+ */
+ oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+ pending = MemoryContextAlloc(TopTransactionContext,
+ sizeof(PendingTablespaceMove));
+ pending->relid = tableOid;
+ pending->newTableSpace = newTableSpace;
+ pending->creating_subid = GetCurrentSubTransactionId();
+ deferred_tablespace_moves = lappend(deferred_tablespace_moves, pending);
+
+ MemoryContextSwitchTo(oldcxt);
+
+ relation_close(rel, NoLock);
+ }
+}
+
/*
* Special handling of ALTER TABLE SET TABLESPACE for relations with no
* storage that have an interest in preserving tablespace.
@@ -20113,6 +20198,100 @@ PreCommit_on_commit_actions(void)
}
}
+
+static int
+deferred_tablespace_move_compare_relid(const ListCell *a, const ListCell *b)
+{
+ PendingTablespaceMove *ca = lfirst_node(PendingTablespaceMove, a);
+ PendingTablespaceMove *cb = lfirst_node(PendingTablespaceMove, b);
+ int t = pg_cmp_u32(ca->relid, cb->relid);
+
+ if (t == 0) /* compare pointers for stability */
+ t = (a < b) ? -1 : 1;
+ return t;
+}
+
+/*
+ * Perform the file copies that might have been deferred during the
+ * transaction.
+ * See also: ATRelationIsFreeToMove
+ */
+void
+PreCommit_deferred_tablespace_moves(void)
+{
+ ListCell *lc;
+ PendingTablespaceMove *prev = NULL;
+ PendingTablespaceMove *pending = NULL;
+
+ /*
+ * Cluster moves by relation id, then for each relation apply the last
+ * change.
+ */
+ list_sort(deferred_tablespace_moves, deferred_tablespace_move_compare_relid);
+ foreach(lc, deferred_tablespace_moves)
+ {
+ pending = (PendingTablespaceMove *) lfirst(lc);
+ if (prev != NULL && prev->relid != pending->relid)
+ {
+ ATExecSetTableSpaceCopy(prev->relid, prev->newTableSpace,
+ AccessExclusiveLock);
+ }
+ prev = pending;
+ }
+
+ if (prev != NULL)
+ ATExecSetTableSpaceCopy(prev->relid, prev->newTableSpace,
+ AccessExclusiveLock);
+ deferred_tablespace_moves = NIL;
+}
+
+/*
+ * Propagate deferred tablespace moves from sub transactions.
+ * The move should be performed only at the commit of the top level
+ * transaction.
+ */
+void
+AtEOSubXact_deferred_tablespace_moves(bool isCommit, SubTransactionId mySubid,
+ SubTransactionId parentSubid)
+{
+ ListCell *cur_item;
+
+ foreach(cur_item, deferred_tablespace_moves)
+ {
+ PendingTablespaceMove *pending = (PendingTablespaceMove *) lfirst(cur_item);
+
+ if (pending->creating_subid != mySubid)
+ continue;
+
+ if (isCommit)
+ {
+ pending->creating_subid = parentSubid;
+ continue;
+ }
+
+ deferred_tablespace_moves = foreach_delete_current(deferred_tablespace_moves,
+ cur_item);
+ pfree(pending);
+ }
+}
+
+/*
+ * Clean up deferred tablespace moves list at top level transaction
+ */
+void
+AtEOXact_deferred_tablespace_moves(bool isCommit)
+{
+ if (!isCommit)
+ {
+ /*
+ * TopTransactionContext is about to be reset, so just drop the
+ * pointer. Do not pfree entries here.
+ */
+ deferred_tablespace_moves = NIL;
+ }
+}
+
+
/*
* Post-commit or post-abort cleanup for ON COMMIT management.
*
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index c3d8518cb62..c964d0c7bf7 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -93,8 +93,13 @@ extern void check_of_type(HeapTuple typetuple);
extern void register_on_commit_action(Oid relid, OnCommitAction action);
extern void remove_on_commit_action(Oid relid);
+extern void PreCommit_deferred_tablespace_moves(void);
extern void PreCommit_on_commit_actions(void);
+extern void AtEOXact_deferred_tablespace_moves(bool isCommit);
extern void AtEOXact_on_commit_actions(bool isCommit);
+extern void AtEOSubXact_deferred_tablespace_moves(bool isCommit,
+ SubTransactionId mySubid,
+ SubTransactionId parentSubid);
extern void AtEOSubXact_on_commit_actions(bool isCommit,
SubTransactionId mySubid,
SubTransactionId parentSubid);
--
2.53.0
[application/octet-stream] v2-0001-logging-and-testcase.patch (11.1K, ../../CAE8JnxNZCPQOXHRRYtyUhWxLB=GNKEAkUEQ+7WjBLH17Jd+xnQ@mail.gmail.com/5-v2-0001-logging-and-testcase.patch)
download | inline diff:
From 740624585a961a54a21ed5998a3fc5c772e84b30 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 08:42:31 +0100
Subject: [PATCH-v2 1/3] logging and testcase
This patch adds clearer logging and a (failing) test case on regress tablespace
to be fixed by the next patch.
To facilitate the review this patch keep DEBUG1 messages in the testcase
To spot the bug keep an eie on repeated ctids, e.g.
SELECT ctid, a FROM defer_t ORDER BY a;
ctid | a
-------+---
(0,1) | 1
(0,2) | 3 <-
(0,2) | 3 <-
(0,3) | 4
---
src/backend/catalog/storage.c | 13 ++
src/backend/commands/tablecmds.c | 8 ++
src/backend/commands/tablespace.c | 8 ++
src/backend/utils/cache/relcache.c | 5 +
src/test/regress/sql/tablespace.sql | 190 ++++++++++++++++++++++++++++
5 files changed, 224 insertions(+)
diff --git a/src/backend/catalog/storage.c b/src/backend/catalog/storage.c
index e443a4993c5..db4653b27e8 100644
--- a/src/backend/catalog/storage.c
+++ b/src/backend/catalog/storage.c
@@ -26,6 +26,7 @@
#include "access/xlogutils.h"
#include "catalog/storage.h"
#include "catalog/storage_xlog.h"
+#include "common/relpath.h"
#include "miscadmin.h"
#include "pgstat.h"
#include "storage/bulk_write.h"
@@ -150,6 +151,9 @@ RelationCreateStorage(RelFileLocator rlocator, char relpersistence,
srel = smgropen(rlocator, procNumber);
smgrcreate(srel, MAIN_FORKNUM, false);
+ elog(DEBUG1, "RelationCreateStorage: %s",
+ relpathperm(rlocator, MAIN_FORKNUM).str);
+
if (needs_wal)
log_smgrcreate(&srel->smgr_rlocator.locator, MAIN_FORKNUM);
@@ -208,6 +212,10 @@ RelationDropStorage(Relation rel)
{
PendingRelDelete *pending;
+ elog(DEBUG1, "RelationDropStorage: oid %u %s",
+ RelationGetRelid(rel),
+ relpathperm(rel->rd_locator, MAIN_FORKNUM).str);
+
/* Add the relation to the list of stuff to delete at commit */
pending = (PendingRelDelete *)
MemoryContextAlloc(TopMemoryContext, sizeof(PendingRelDelete));
@@ -701,6 +709,11 @@ smgrDoPendingDeletes(bool isCommit)
{
SMgrRelation srel;
+ elog(DEBUG1,
+ "smgrDoPendingDeletes: %s %s",
+ isCommit ? "commit" : "abort",
+ relpathperm(pending->rlocator, MAIN_FORKNUM).str);
+
srel = smgropen(pending->rlocator, pending->procNumber);
/* allocate the initial array, or extend it, if needed */
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 2f073ddb84a..1e6d0b1611f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -69,6 +69,7 @@
#include "commands/user.h"
#include "commands/vacuum.h"
#include "common/int.h"
+#include "common/relpath.h"
#include "executor/executor.h"
#include "foreign/fdwapi.h"
#include "foreign/foreign.h"
@@ -17560,6 +17561,13 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
newrlocator.relNumber = newrelfilenumber;
newrlocator.spcOid = newTableSpace;
+ elog(DEBUG1,
+ "SET TABLESPACE copy: rel \"%s\" oid %u, from %s to %s",
+ RelationGetRelationName(rel),
+ RelationGetRelid(rel),
+ relpathperm(rel->rd_locator, MAIN_FORKNUM).str,
+ relpathperm(newrlocator, MAIN_FORKNUM).str);
+
/* hand off to AM to actually create new rel storage and copy the data */
if (rel->rd_rel->relkind == RELKIND_INDEX)
{
diff --git a/src/backend/commands/tablespace.c b/src/backend/commands/tablespace.c
index e01fb2db913..bba656e9b78 100644
--- a/src/backend/commands/tablespace.c
+++ b/src/backend/commands/tablespace.c
@@ -176,6 +176,8 @@ TablespaceCreateDbspace(Oid spcOid, Oid dbOid, bool isRedo)
errmsg("could not create directory \"%s\": %m",
dir)));
}
+
+ elog(DEBUG1, "TablespaceCreateDbspace: %s", dir);
}
LWLockRelease(TablespaceCreateLock);
@@ -677,6 +679,12 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid)
errmsg("could not create symbolic link \"%s\": %m",
linkloc)));
+ if (in_place)
+ elog(DEBUG1, "tablespace directories: %s", linkloc);
+ else
+ elog(DEBUG1, "tablespace directories: %s -> %s",
+ linkloc, location_with_version_dir);
+
pfree(linkloc);
pfree(location_with_version_dir);
}
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index d8f04a05309..b73c42e7519 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -61,6 +61,7 @@
#include "catalog/pg_statistic_ext.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_tablespace.h"
+#include "common/relpath.h"
#include "catalog/pg_trigger.h"
#include "catalog/pg_type.h"
#include "catalog/schemapg.h"
@@ -3975,6 +3976,10 @@ RelationAssumeNewRelfilelocator(Relation relation)
if (relation->rd_firstRelfilelocatorSubid == InvalidSubTransactionId)
relation->rd_firstRelfilelocatorSubid = relation->rd_newRelfilelocatorSubid;
+ elog(DEBUG1, "RelationAssumeNewRelfilelocator: oid %u %s",
+ RelationGetRelid(relation),
+ relpathperm(relation->rd_locator, MAIN_FORKNUM).str);
+
/* Flag relation as needing eoxact cleanup (to clear these fields) */
EOXactListAdd(relation);
}
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..d58cd4af3d5 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -437,6 +437,196 @@ name";
-- Should succeed
DROP TABLESPACE regress_tblspace_renamed;
+--
+-- Deferred heap copy for ALTER TABLE SET TABLESPACE on indexed tables
+--
+SET allow_in_place_tablespaces = true;
+SET client_min_messages = DEBUG1;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+CREATE TABLESPACE regress_tblspace LOCATION '';
+CREATE TABLESPACE regress_tblspace2 LOCATION '';
+PREPARE check_tablespace AS
+ SELECT n.nspname, c.relname, coalesce(s.spcname, '(default)') AS tablespace
+ FROM pg_class c
+ LEFT JOIN pg_tablespace s ON s.oid = c.reltablespace
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE c.relname = 'defer_t'
+ ORDER BY 1;
+
+-- COMMIT
+CREATE TABLE defer_t (a int);
+EXECUTE check_tablespace;
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- ROLLBACK
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+ROLLBACK;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- COMMIT ENDING UP IN THE SAME SPACE
+
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE pg_default;
+INSERT INTO defer_t VALUES (4);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- PREPARE TRANSACTION, COMMIT PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_commit';
+COMMIT PREPARED 'defer_tblsp_prep_commit';
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- PREPARE TRANSACTION, ROLLBACK PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_rollback';
+ROLLBACK PREPARED 'defer_tblsp_prep_rollback';
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: move in savepoint, subcommit, then top-level commit
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (6), (7);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: move in savepoint, then rollback to savepoint
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+ROLLBACK TO SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: subcommit move, then top-level rollback cancels move
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+ROLLBACK;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: two deferred moves in one transaction
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (6);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE regress_tblspace2;
+INSERT INTO defer_t VALUES (6);
+ROLLBACK;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+RESET enable_sort;
+
+DROP TABLESPACE regress_tblspace;
+DROP TABLESPACE regress_tblspace2;
+
DROP SCHEMA testschema CASCADE;
DROP ROLE regress_tablespace_user1;
--
2.53.0
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-29 11:45 Manu <manuelreyesbravo@gmail.com>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Manu @ 2026-09-29 11:45 UTC (permalink / raw)
To: Alexandre Felipe <o.alexandre.felipe@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org
Hi Al,
I built v2 on master (82d31451606) with --enable-cassert and checked the
two cases, plus your question.
v2 fixes both. The double SET TABLESPACE now lands right: a table moved
to ts and then back to pg_default in one transaction ends in pg_default,
where v1 left it in ts. The original recipe is still fixed: the
rollback + INSERT btree case does not trap and bt_index_check reports
nothing, against master where it fails the _bt_posting_valid assertion.
One build problem: v2-0002 does not compile with --enable-cassert.
deferred_tablespace_move_compare_relid() reads its cells with
lfirst_node(PendingTablespaceMove, ...), but PendingTablespaceMove is a
plain struct, not a Node, so T_PendingTablespaceMove is undeclared.
Without assertions castNode() is a plain cast, so the build passes, which
is why it slips through, but the cassert buildfarm animals would fail on
it. PreCommit_on_commit_actions() walks on_commits with a plain
(OnCommitItem *) lfirst() cast; the same here builds under cassert.
> I just noticed that if someone check the pg_tablespaces inside the
> transaction they will get unexpected results. (Is that something we
> need to fix?)
I can reproduce it. With v2, inside the transaction
pg_class.reltablespace for an indexed table still reads the old
tablespace until commit, since the whole move is deferred. On master
SET TABLESPACE updates the catalog at execution time, so a query in the
same transaction sees the new tablespace right away. So v2 does change
that observable behavior.
Whether it is worth fixing is your call. The one thing I'd note is that
the alternative, showing the pending tablespace in the catalog within
the transaction, would leave reltablespace pointing at a tablespace the
file has not reached until commit, so it isn't only a matter of moving
the catalog update earlier. I'll leave the design to you; I mainly
wanted to confirm the behavior is real and that it differs from master.
Regards,
Manu
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-29 19:10 Alexandre Felipe <o.alexandre.felipe@gmail.com>
parent: Manu <manuelreyesbravo@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Alexandre Felipe @ 2026-09-29 19:10 UTC (permalink / raw)
To: Manu <manuelreyesbravo@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org
Thank you Manu,
Trying to keep iterations quick :)
On Tue, Sep 29, 2026 at 12:45 PM Manu <manuelreyesbravo@gmail.com> wrote:
> Hi Al,
>
> I built v2 on master (82d31451606) with --enable-cassert and checked the
> two cases, plus your question.
v2 fixes both. The double SET TABLESPACE now lands right: a table moved
> to ts and then back to pg_default in one transaction ends in pg_default,
> where v1 left it in ts. The original recipe is still fixed: the
> rollback + INSERT btree case does not trap and bt_index_check reports
> nothing, against master where it fails the _bt_posting_valid assertion.
Yes
> One build problem: v2-0002 does not compile with --enable-cassert.
Fixed, using pointer casts directly (indentation looks weird to me, but that
was pg_indent's choice).
> > I just noticed that if someone check the pg_tablespaces inside the
> > transaction they will get unexpected results. (Is that something we
> > need to fix?)
>
> I can reproduce it. With v2, inside the transaction
> pg_class.reltablespace for an indexed table still reads the old
> tablespace until commit, since the whole move is deferred. On master
> SET TABLESPACE updates the catalog at execution time, so a query in the
> same transaction sees the new tablespace right away. So v2 does change
> that observable behavior.
>
> Whether it is worth fixing is your call. The one thing I'd note is that
> the alternative, showing the pending tablespace in the catalog within
> the transaction, would leave reltablespace pointing at a tablespace the
> file has not reached until commit, so it isn't only a matter of moving
> the catalog update earlier. I'll leave the design to you; I mainly
> wanted to confirm the behavior is real and that it differs from master.
v3 adds a paragraph about deferred copy and clarifies that catalog
updates might not be visible within the transaction, I think it is good
that it reflects the physical location of the relation. Reading the
preceding
paragraph I decided that it was worth adding a test case for partitioned
tables.
The tests for this are already getting long so I moved to a separate file,
tablespace_xact, in parallel with tablespace instead of appending on it.
Regards,
Alexandre
Attachments:
[application/octet-stream] v3-0003-establish-expectations.patch (15.0K, ../../CAE8JnxO2a5xj5maYL9UbwXt7pWvxr-ZRpx7sscmi9=+Z4Fa==g@mail.gmail.com/3-v3-0003-establish-expectations.patch)
download | inline diff:
From d937987683b6aedd7e8086a7c797ed0800fc1852 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 20:18:05 +0100
Subject: [PATCH-v3 3/4] establish expectations
wrap up with the regress reference and disable debugging
---
src/test/regress/expected/tablespace_xact.out | 482 +++++++++++++++++-
src/test/regress/sql/tablespace_xact.sql | 4 +-
2 files changed, 482 insertions(+), 4 deletions(-)
diff --git a/src/test/regress/expected/tablespace_xact.out b/src/test/regress/expected/tablespace_xact.out
index 85d3d35f332..22338f9ec32 100644
--- a/src/test/regress/expected/tablespace_xact.out
+++ b/src/test/regress/expected/tablespace_xact.out
@@ -1 +1,481 @@
--- place holder --
\ No newline at end of file
+--
+-- Deferred heap copy for ALTER TABLE SET TABLESPACE on indexed tables
+--
+SET allow_in_place_tablespaces = true;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+CREATE SCHEMA tablespace_xact;
+SET search_path TO tablespace_xact;
+CREATE TABLESPACE xact_tblspace LOCATION '';
+CREATE TABLESPACE xact_tblspace2 LOCATION '';
+PREPARE check_tablespace AS
+ SELECT c.relname, coalesce(s.spcname, '(default)') AS tablespace
+ FROM pg_class c
+ LEFT JOIN pg_tablespace s ON s.oid = c.reltablespace
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'tablespace_xact'
+ ORDER BY 1;
+-- COMMIT
+CREATE TABLE defer_t (a int);
+EXECUTE check_tablespace;
+ relname | tablespace
+---------+------------
+ defer_t | (default)
+(1 row)
+
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (3);
+COMMIT;
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+---------------
+ defer_t | xact_tblspace
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+(5 rows)
+
+DROP TABLE defer_t;
+-- ROLLBACK
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (3);
+ROLLBACK;
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+------------
+ defer_t | (default)
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,4) | 4
+ (0,5) | 5
+(3 rows)
+
+DROP TABLE defer_t;
+-- COMMIT ENDING UP IN THE SAME SPACE
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE pg_default;
+INSERT INTO defer_t VALUES (4);
+COMMIT;
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+------------
+ defer_t | (default)
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+(5 rows)
+
+DROP TABLE defer_t;
+-- PREPARE TRANSACTION, COMMIT PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_commit';
+COMMIT PREPARED 'defer_tblsp_prep_commit';
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+---------------
+ defer_t | xact_tblspace
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+(5 rows)
+
+DROP TABLE defer_t;
+-- PREPARE TRANSACTION, ROLLBACK PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace2;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_rollback';
+ROLLBACK PREPARED 'defer_tblsp_prep_rollback';
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+------------
+ defer_t | (default)
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,4) | 4
+ (0,5) | 5
+(3 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: move in savepoint, subcommit, then top-level commit
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+---------------
+ defer_t | xact_tblspace
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (6), (7);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+ (0,6) | 6
+ (0,7) | 7
+(7 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: move in savepoint, then rollback to savepoint
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+ROLLBACK TO SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+------------
+ defer_t | (default)
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,5) | 5
+ (0,6) | 6
+ (0,7) | 7
+ (0,8) | 8
+(6 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: subcommit move, then top-level rollback cancels move
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+ROLLBACK;
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+------------
+ defer_t | (default)
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,6) | 6
+ (0,7) | 7
+ (0,8) | 8
+(4 rows)
+
+DROP TABLE defer_t;
+-- Subtransaction: two deferred moves in one transaction
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace2;
+INSERT INTO defer_t VALUES (6);
+COMMIT;
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+----------------
+ defer_t | xact_tblspace2
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,2) | 2
+ (0,3) | 3
+ (0,4) | 4
+ (0,5) | 5
+ (0,6) | 6
+ (0,7) | 7
+ (0,8) | 8
+ (0,9) | 9
+(9 rows)
+
+DROP TABLE defer_t;
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace2;
+INSERT INTO defer_t VALUES (6);
+ROLLBACK;
+EXECUTE check_tablespace;
+ relname | tablespace
+-------------+------------
+ defer_t | (default)
+ defer_t_idx | (default)
+(2 rows)
+
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+ ctid | a
+-------+---
+ (0,1) | 1
+ (0,7) | 7
+ (0,8) | 8
+ (0,9) | 9
+(4 rows)
+
+DROP TABLE defer_t;
+-- Partitioned table: SET TABLESPACE in a transaction; new partitions use it
+CREATE TABLE defer_part (a int) PARTITION BY RANGE (a);
+CREATE TABLE defer_part_p0 PARTITION OF defer_part FOR VALUES FROM (0) TO (10);
+CREATE INDEX ON defer_part(a);
+INSERT INTO defer_part VALUES (1);
+EXECUTE check_tablespace;
+ relname | tablespace
+---------------------+------------
+ defer_part | (default)
+ defer_part_a_idx | (default)
+ defer_part_p0 | (default)
+ defer_part_p0_a_idx | (default)
+(4 rows)
+
+BEGIN;
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace;
+-- defer_part tablespace is updated, but partitions are not moved
+EXECUTE check_tablespace;
+ relname | tablespace
+---------------------+---------------
+ defer_part | xact_tblspace
+ defer_part_a_idx | (default)
+ defer_part_p0 | (default)
+ defer_part_p0_a_idx | (default)
+(4 rows)
+
+-- new partitions created in the new table space
+CREATE TABLE defer_part_p1 PARTITION OF defer_part FOR VALUES FROM (10) TO (20);
+INSERT INTO defer_part VALUES (15);
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace2;
+CREATE TABLE defer_part_p2 PARTITION OF defer_part FOR VALUES FROM (20) TO (30);
+ALTER TABLE defer_part SET TABLESPACE pg_default;
+CREATE TABLE defer_part_p3 PARTITION OF defer_part FOR VALUES FROM (30) TO (40);
+EXECUTE check_tablespace;
+ relname | tablespace
+---------------------+----------------
+ defer_part | (default)
+ defer_part_a_idx | (default)
+ defer_part_p0 | (default)
+ defer_part_p0_a_idx | (default)
+ defer_part_p1 | xact_tblspace
+ defer_part_p1_a_idx | (default)
+ defer_part_p2 | xact_tblspace2
+ defer_part_p2_a_idx | (default)
+ defer_part_p3 | (default)
+ defer_part_p3_a_idx | (default)
+(10 rows)
+
+ROLLBACK; -- rollback delete partitions created in the transaction
+EXECUTE check_tablespace;
+ relname | tablespace
+---------------------+------------
+ defer_part | (default)
+ defer_part_a_idx | (default)
+ defer_part_p0 | (default)
+ defer_part_p0_a_idx | (default)
+(4 rows)
+
+-- Partitioned table: SET TABLESPACE in a transaction; new partitions use it
+CREATE TABLE defer_part (a int) PARTITION BY RANGE (a);
+ERROR: relation "defer_part" already exists
+CREATE TABLE defer_part_p0 PARTITION OF defer_part FOR VALUES FROM (0) TO (10);
+ERROR: relation "defer_part_p0" already exists
+INSERT INTO defer_part VALUES (1);
+EXECUTE check_tablespace;
+ relname | tablespace
+---------------------+------------
+ defer_part | (default)
+ defer_part_a_idx | (default)
+ defer_part_p0 | (default)
+ defer_part_p0_a_idx | (default)
+(4 rows)
+
+BEGIN;
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace;
+-- defer_part tablespace is updated, but partitions are not moved
+EXECUTE check_tablespace;
+ relname | tablespace
+---------------------+---------------
+ defer_part | xact_tblspace
+ defer_part_a_idx | (default)
+ defer_part_p0 | (default)
+ defer_part_p0_a_idx | (default)
+(4 rows)
+
+-- new partitions created in the new table space
+CREATE TABLE defer_part_p1 PARTITION OF defer_part FOR VALUES FROM (10) TO (20);
+INSERT INTO defer_part VALUES (15);
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace2;
+CREATE TABLE defer_part_p2 PARTITION OF defer_part FOR VALUES FROM (20) TO (30);
+ALTER TABLE defer_part SET TABLESPACE pg_default;
+CREATE TABLE defer_part_p3 PARTITION OF defer_part FOR VALUES FROM (30) TO (40);
+EXECUTE check_tablespace;
+ relname | tablespace
+---------------------+----------------
+ defer_part | (default)
+ defer_part_a_idx | (default)
+ defer_part_p0 | (default)
+ defer_part_p0_a_idx | (default)
+ defer_part_p1 | xact_tblspace
+ defer_part_p1_a_idx | (default)
+ defer_part_p2 | xact_tblspace2
+ defer_part_p2_a_idx | (default)
+ defer_part_p3 | (default)
+ defer_part_p3_a_idx | (default)
+(10 rows)
+
+ROLLBACK; -- rollback delete partitions created in the transaction
+EXECUTE check_tablespace;
+ relname | tablespace
+---------------------+------------
+ defer_part | (default)
+ defer_part_a_idx | (default)
+ defer_part_p0 | (default)
+ defer_part_p0_a_idx | (default)
+(4 rows)
+
+BEGIN;
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace;
+CREATE TABLE defer_part_p1 PARTITION OF defer_part FOR VALUES FROM (10) TO (20);
+INSERT INTO defer_part VALUES (15);
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace2;
+CREATE TABLE defer_part_p2 PARTITION OF defer_part FOR VALUES FROM (20) TO (30);
+ALTER TABLE defer_part SET TABLESPACE pg_default;
+CREATE TABLE defer_part_p3 PARTITION OF defer_part FOR VALUES FROM (30) TO (40);
+COMMIT; -- commit persists partitions created in the transaction
+EXECUTE check_tablespace;
+ relname | tablespace
+---------------------+----------------
+ defer_part | (default)
+ defer_part_a_idx | (default)
+ defer_part_p0 | (default)
+ defer_part_p0_a_idx | (default)
+ defer_part_p1 | xact_tblspace
+ defer_part_p1_a_idx | (default)
+ defer_part_p2 | xact_tblspace2
+ defer_part_p2_a_idx | (default)
+ defer_part_p3 | (default)
+ defer_part_p3_a_idx | (default)
+(10 rows)
+
+DROP TABLE defer_part;
+EXECUTE check_tablespace;
+ relname | tablespace
+---------+------------
+(0 rows)
+
+DROP TABLESPACE xact_tblspace;
+DROP TABLESPACE xact_tblspace2;
+DROP SCHEMA tablespace_xact CASCADE;
diff --git a/src/test/regress/sql/tablespace_xact.sql b/src/test/regress/sql/tablespace_xact.sql
index 43fc20c7505..7fe99a5df1c 100644
--- a/src/test/regress/sql/tablespace_xact.sql
+++ b/src/test/regress/sql/tablespace_xact.sql
@@ -3,7 +3,6 @@
-- Deferred heap copy for ALTER TABLE SET TABLESPACE on indexed tables
--
SET allow_in_place_tablespaces = true;
-SET client_min_messages = DEBUG1;
SET enable_seqscan = off;
SET enable_bitmapscan = off;
@@ -214,7 +213,6 @@ EXECUTE check_tablespace;
-- Partitioned table: SET TABLESPACE in a transaction; new partitions use it
CREATE TABLE defer_part (a int) PARTITION BY RANGE (a);
CREATE TABLE defer_part_p0 PARTITION OF defer_part FOR VALUES FROM (0) TO (10);
-CREATE INDEX ON defer_part(a);
INSERT INTO defer_part VALUES (1);
EXECUTE check_tablespace;
BEGIN;
@@ -248,4 +246,4 @@ EXECUTE check_tablespace;
DROP TABLESPACE xact_tblspace;
DROP TABLESPACE xact_tblspace2;
-DROP SCHEMA CASCADE tablespace_xact;
\ No newline at end of file
+DROP SCHEMA tablespace_xact CASCADE;
\ No newline at end of file
--
2.53.0
[application/octet-stream] v3-0002-fix-deferred-relation-copy.patch (10.4K, ../../CAE8JnxO2a5xj5maYL9UbwXt7pWvxr-ZRpx7sscmi9=+Z4Fa==g@mail.gmail.com/4-v3-0002-fix-deferred-relation-copy.patch)
download | inline diff:
From 7432b0ca5e224b8172b9ff719cfb190159351356 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 19:23:08 +0100
Subject: [PATCH-v3 2/4] fix: deferred relation copy
When running ALTER TABLE SET TABLESPACE on a table externally
referenced by TID, e.g. having indices, outside the transaction, defer
the copy of the relation fields to the commit, in order to keep the
index consistent with the heap outside the transaction.
---
src/backend/access/transam/xact.c | 14 +++
src/backend/commands/tablecmds.c | 181 +++++++++++++++++++++++++++++-
src/include/commands/tablecmds.h | 5 +
3 files changed, 199 insertions(+), 1 deletion(-)
diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c
index ebb010853cf..e76d72851fb 100644
--- a/src/backend/access/transam/xact.c
+++ b/src/backend/access/transam/xact.c
@@ -2351,6 +2351,12 @@ CommitTransaction(void)
*/
PreCommit_on_commit_actions();
+ /*
+ * Perform the physical copy of relation files that were deferred during
+ * execution of SET TABLESPACE commands.
+ */
+ PreCommit_deferred_tablespace_moves();
+
/*
* Synchronize files that are created and not WAL-logged during this
* transaction. This must happen before AtEOXact_RelationMap(), so that we
@@ -2508,6 +2514,7 @@ CommitTransaction(void)
AtEOXact_SPI(true);
AtEOXact_Enum();
AtEOXact_on_commit_actions(true);
+ AtEOXact_deferred_tablespace_moves(true);
AtEOXact_Namespace(true, is_parallel_worker);
AtEOXact_SMgr();
AtEOXact_Files(true);
@@ -2613,6 +2620,7 @@ PrepareTransaction(void)
* cursors, to avoid dangling-reference problems)
*/
PreCommit_on_commit_actions();
+ PreCommit_deferred_tablespace_moves();
/*
* Synchronize files that are created and not WAL-logged during this
@@ -2804,6 +2812,7 @@ PrepareTransaction(void)
AtEOXact_SPI(true);
AtEOXact_Enum();
AtEOXact_on_commit_actions(true);
+ AtEOXact_deferred_tablespace_moves(true);
AtEOXact_Namespace(true, false);
AtEOXact_SMgr();
AtEOXact_Files(true);
@@ -3035,6 +3044,7 @@ AbortTransaction(void)
AtEOXact_SPI(false);
AtEOXact_Enum();
AtEOXact_on_commit_actions(false);
+ AtEOXact_deferred_tablespace_moves(false);
AtEOXact_Namespace(false, is_parallel_worker);
AtEOXact_SMgr();
AtEOXact_Files(false);
@@ -5240,6 +5250,8 @@ CommitSubTransaction(void)
AtEOSubXact_SPI(true, s->subTransactionId);
AtEOSubXact_on_commit_actions(true, s->subTransactionId,
s->parent->subTransactionId);
+ AtEOSubXact_deferred_tablespace_moves(true, s->subTransactionId,
+ s->parent->subTransactionId);
AtEOSubXact_Namespace(true, s->subTransactionId,
s->parent->subTransactionId);
AtEOSubXact_Files(true, s->subTransactionId,
@@ -5415,6 +5427,8 @@ AbortSubTransaction(void)
AtEOSubXact_SPI(false, s->subTransactionId);
AtEOSubXact_on_commit_actions(false, s->subTransactionId,
s->parent->subTransactionId);
+ AtEOSubXact_deferred_tablespace_moves(false, s->subTransactionId,
+ s->parent->subTransactionId);
AtEOSubXact_Namespace(false, s->subTransactionId,
s->parent->subTransactionId);
AtEOSubXact_Files(false, s->subTransactionId,
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 1e6d0b1611f..beb4d2e766a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -133,6 +133,16 @@ typedef struct OnCommitItem
static List *on_commits = NIL;
+typedef struct PendingTablespaceMove
+{
+ Oid relid;
+ Oid newTableSpace;
+ SubTransactionId creating_subid;
+} PendingTablespaceMove;
+
+static List *deferred_tablespace_moves = NIL;
+
+
/*
* State information for ALTER TABLE
@@ -695,6 +705,9 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
+static bool ATRelationIsFreeToMove(Relation rel);
+static void ATExecSetTableSpaceCopy(Oid tableOid, Oid newTableSpace,
+ LOCKMODE lockmode);
static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
@@ -17516,7 +17529,7 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpaceCopy(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
{
Relation rel;
Oid reltoastrelid;
@@ -17539,6 +17552,9 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
return;
}
+ elog(DEBUG1, "ATExecSetTableSpaceCopy: rel %d to tblspace %d (copying)",
+ tableOid, newTableSpace
+ );
reltoastrelid = rel->rd_rel->reltoastrelid;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
@@ -17608,6 +17624,75 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
list_free(reltoastidxids);
}
+
+/*
+ * ATRelationIsFreeToMove
+ *
+ * Determines whether a relation file can be copied immediately or it has to
+ * wait until the transaction commit.
+ *
+ * When the relation has indices in the old tablespace because we have to
+ * keep also invisible rows produced after the tablespace update must remain
+ * consistent in the case of a rollback.
+ */
+static bool
+ATRelationIsFreeToMove(Relation rel)
+{
+ char relkind = rel->rd_rel->relkind;
+
+ if (relkind != RELKIND_RELATION && relkind != RELKIND_MATVIEW)
+ return true;
+
+ return RelationGetIndexList(rel) == NIL;
+}
+
+/*
+ * ATExecSetTableSpace
+ *
+ * Either copy the relation files, or push the an item to the list of
+ * deferred tablespace moves, that are handled at commit.
+ */
+static void
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+{
+ Relation rel;
+ PendingTablespaceMove *pending;
+
+ rel = relation_open(tableOid, lockmode);
+
+ if (ATRelationIsFreeToMove(rel))
+ {
+ relation_close(rel, NoLock);
+ ATExecSetTableSpaceCopy(tableOid, newTableSpace, lockmode);
+ }
+ else
+ {
+ MemoryContext oldcxt;
+
+ CheckRelationTableSpaceMove(rel, newTableSpace);
+
+ elog(DEBUG1, "ATExecSetTableSpace: rel %d to tblspace %d (deferred copy)",
+ tableOid, newTableSpace
+ );
+
+ /*
+ * Keep both the list cells and entries in TopTransactionContext for
+ * the whole transaction, like on_commits uses CacheMemoryContext.
+ */
+ oldcxt = MemoryContextSwitchTo(TopTransactionContext);
+ pending = MemoryContextAlloc(TopTransactionContext,
+ sizeof(PendingTablespaceMove));
+ pending->relid = tableOid;
+ pending->newTableSpace = newTableSpace;
+ pending->creating_subid = GetCurrentSubTransactionId();
+ deferred_tablespace_moves = lappend(deferred_tablespace_moves, pending);
+
+ MemoryContextSwitchTo(oldcxt);
+
+ relation_close(rel, NoLock);
+ }
+}
+
/*
* Special handling of ALTER TABLE SET TABLESPACE for relations with no
* storage that have an interest in preserving tablespace.
@@ -20113,6 +20198,100 @@ PreCommit_on_commit_actions(void)
}
}
+
+static int
+deferred_tablespace_move_compare_relid(const ListCell *a, const ListCell *b)
+{
+ PendingTablespaceMove *ca = (PendingTablespaceMove *) a;
+ PendingTablespaceMove *cb = (PendingTablespaceMove *) b;
+ int t = pg_cmp_u32(ca->relid, cb->relid);
+
+ if (t == 0) /* compare pointers for stability */
+ t = (a < b) ? -1 : 1;
+ return t;
+}
+
+/*
+ * Perform the file copies that might have been deferred during the
+ * transaction.
+ * See also: ATRelationIsFreeToMove
+ */
+void
+PreCommit_deferred_tablespace_moves(void)
+{
+ ListCell *lc;
+ PendingTablespaceMove *prev = NULL;
+ PendingTablespaceMove *pending = NULL;
+
+ /*
+ * Cluster moves by relation id, then for each relation apply the last
+ * change.
+ */
+ list_sort(deferred_tablespace_moves, deferred_tablespace_move_compare_relid);
+ foreach(lc, deferred_tablespace_moves)
+ {
+ pending = (PendingTablespaceMove *) lfirst(lc);
+ if (prev != NULL && prev->relid != pending->relid)
+ {
+ ATExecSetTableSpaceCopy(prev->relid, prev->newTableSpace,
+ AccessExclusiveLock);
+ }
+ prev = pending;
+ }
+
+ if (prev != NULL)
+ ATExecSetTableSpaceCopy(prev->relid, prev->newTableSpace,
+ AccessExclusiveLock);
+ deferred_tablespace_moves = NIL;
+}
+
+/*
+ * Propagate deferred tablespace moves from sub transactions.
+ * The move should be performed only at the commit of the top level
+ * transaction.
+ */
+void
+AtEOSubXact_deferred_tablespace_moves(bool isCommit, SubTransactionId mySubid,
+ SubTransactionId parentSubid)
+{
+ ListCell *cur_item;
+
+ foreach(cur_item, deferred_tablespace_moves)
+ {
+ PendingTablespaceMove *pending = (PendingTablespaceMove *) lfirst(cur_item);
+
+ if (pending->creating_subid != mySubid)
+ continue;
+
+ if (isCommit)
+ {
+ pending->creating_subid = parentSubid;
+ continue;
+ }
+
+ deferred_tablespace_moves = foreach_delete_current(deferred_tablespace_moves,
+ cur_item);
+ pfree(pending);
+ }
+}
+
+/*
+ * Clean up deferred tablespace moves list at top level transaction
+ */
+void
+AtEOXact_deferred_tablespace_moves(bool isCommit)
+{
+ if (!isCommit)
+ {
+ /*
+ * TopTransactionContext is about to be reset, so just drop the
+ * pointer. Do not pfree entries here.
+ */
+ deferred_tablespace_moves = NIL;
+ }
+}
+
+
/*
* Post-commit or post-abort cleanup for ON COMMIT management.
*
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index c3d8518cb62..c964d0c7bf7 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -93,8 +93,13 @@ extern void check_of_type(HeapTuple typetuple);
extern void register_on_commit_action(Oid relid, OnCommitAction action);
extern void remove_on_commit_action(Oid relid);
+extern void PreCommit_deferred_tablespace_moves(void);
extern void PreCommit_on_commit_actions(void);
+extern void AtEOXact_deferred_tablespace_moves(bool isCommit);
extern void AtEOXact_on_commit_actions(bool isCommit);
+extern void AtEOSubXact_deferred_tablespace_moves(bool isCommit,
+ SubTransactionId mySubid,
+ SubTransactionId parentSubid);
extern void AtEOSubXact_on_commit_actions(bool isCommit,
SubTransactionId mySubid,
SubTransactionId parentSubid);
--
2.53.0
[application/octet-stream] v3-0004-establish-expected-tablespace.out.patch (291B, ../../CAE8JnxO2a5xj5maYL9UbwXt7pWvxr-ZRpx7sscmi9=+Z4Fa==g@mail.gmail.com/5-v3-0004-establish-expected-tablespace.out.patch)
download | inline diff:
From ba336b61e8178cb847335f5a4e9bbc009363e4b7 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 20:18:05 +0100
Subject: [PATCH-v3 4/4] establish expected/tablespace.out
wrap up with the regress reference and disable debugging
--
2.53.0
[application/octet-stream] v3-0001-logging-and-testcase.patch (14.6K, ../../CAE8JnxO2a5xj5maYL9UbwXt7pWvxr-ZRpx7sscmi9=+Z4Fa==g@mail.gmail.com/6-v3-0001-logging-and-testcase.patch)
download | inline diff:
From 6115ab269fa7fb384f8af0b3aff8cffe3c8dff51 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Wed, 16 Sep 2026 08:42:31 +0100
Subject: [PATCH-v3 1/4] logging and testcase
This patch adds clearer logging and a (failing) test case on regress tablespace
to be fixed by the next patch.
To facilitate the review this patch keep DEBUG1 messages in the testcase
To spot the bug keep an eie on repeated ctids, e.g.
SELECT ctid, a FROM defer_t ORDER BY a;
ctid | a
-------+---
(0,1) | 1
(0,2) | 3 <-
(0,2) | 3 <-
(0,3) | 4
---
src/backend/catalog/storage.c | 13 +
src/backend/commands/tablecmds.c | 8 +
src/backend/commands/tablespace.c | 8 +
src/backend/utils/cache/relcache.c | 5 +
src/test/regress/expected/tablespace_xact.out | 1 +
src/test/regress/parallel_schedule | 2 +-
src/test/regress/sql/tablespace_xact.sql | 251 ++++++++++++++++++
7 files changed, 287 insertions(+), 1 deletion(-)
create mode 100644 src/test/regress/expected/tablespace_xact.out
create mode 100644 src/test/regress/sql/tablespace_xact.sql
diff --git a/src/backend/catalog/storage.c b/src/backend/catalog/storage.c
index e443a4993c5..db4653b27e8 100644
--- a/src/backend/catalog/storage.c
+++ b/src/backend/catalog/storage.c
@@ -26,6 +26,7 @@
#include "access/xlogutils.h"
#include "catalog/storage.h"
#include "catalog/storage_xlog.h"
+#include "common/relpath.h"
#include "miscadmin.h"
#include "pgstat.h"
#include "storage/bulk_write.h"
@@ -150,6 +151,9 @@ RelationCreateStorage(RelFileLocator rlocator, char relpersistence,
srel = smgropen(rlocator, procNumber);
smgrcreate(srel, MAIN_FORKNUM, false);
+ elog(DEBUG1, "RelationCreateStorage: %s",
+ relpathperm(rlocator, MAIN_FORKNUM).str);
+
if (needs_wal)
log_smgrcreate(&srel->smgr_rlocator.locator, MAIN_FORKNUM);
@@ -208,6 +212,10 @@ RelationDropStorage(Relation rel)
{
PendingRelDelete *pending;
+ elog(DEBUG1, "RelationDropStorage: oid %u %s",
+ RelationGetRelid(rel),
+ relpathperm(rel->rd_locator, MAIN_FORKNUM).str);
+
/* Add the relation to the list of stuff to delete at commit */
pending = (PendingRelDelete *)
MemoryContextAlloc(TopMemoryContext, sizeof(PendingRelDelete));
@@ -701,6 +709,11 @@ smgrDoPendingDeletes(bool isCommit)
{
SMgrRelation srel;
+ elog(DEBUG1,
+ "smgrDoPendingDeletes: %s %s",
+ isCommit ? "commit" : "abort",
+ relpathperm(pending->rlocator, MAIN_FORKNUM).str);
+
srel = smgropen(pending->rlocator, pending->procNumber);
/* allocate the initial array, or extend it, if needed */
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 2f073ddb84a..1e6d0b1611f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -69,6 +69,7 @@
#include "commands/user.h"
#include "commands/vacuum.h"
#include "common/int.h"
+#include "common/relpath.h"
#include "executor/executor.h"
#include "foreign/fdwapi.h"
#include "foreign/foreign.h"
@@ -17560,6 +17561,13 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
newrlocator.relNumber = newrelfilenumber;
newrlocator.spcOid = newTableSpace;
+ elog(DEBUG1,
+ "SET TABLESPACE copy: rel \"%s\" oid %u, from %s to %s",
+ RelationGetRelationName(rel),
+ RelationGetRelid(rel),
+ relpathperm(rel->rd_locator, MAIN_FORKNUM).str,
+ relpathperm(newrlocator, MAIN_FORKNUM).str);
+
/* hand off to AM to actually create new rel storage and copy the data */
if (rel->rd_rel->relkind == RELKIND_INDEX)
{
diff --git a/src/backend/commands/tablespace.c b/src/backend/commands/tablespace.c
index e01fb2db913..bba656e9b78 100644
--- a/src/backend/commands/tablespace.c
+++ b/src/backend/commands/tablespace.c
@@ -176,6 +176,8 @@ TablespaceCreateDbspace(Oid spcOid, Oid dbOid, bool isRedo)
errmsg("could not create directory \"%s\": %m",
dir)));
}
+
+ elog(DEBUG1, "TablespaceCreateDbspace: %s", dir);
}
LWLockRelease(TablespaceCreateLock);
@@ -677,6 +679,12 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid)
errmsg("could not create symbolic link \"%s\": %m",
linkloc)));
+ if (in_place)
+ elog(DEBUG1, "tablespace directories: %s", linkloc);
+ else
+ elog(DEBUG1, "tablespace directories: %s -> %s",
+ linkloc, location_with_version_dir);
+
pfree(linkloc);
pfree(location_with_version_dir);
}
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index d8f04a05309..b73c42e7519 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -61,6 +61,7 @@
#include "catalog/pg_statistic_ext.h"
#include "catalog/pg_subscription.h"
#include "catalog/pg_tablespace.h"
+#include "common/relpath.h"
#include "catalog/pg_trigger.h"
#include "catalog/pg_type.h"
#include "catalog/schemapg.h"
@@ -3975,6 +3976,10 @@ RelationAssumeNewRelfilelocator(Relation relation)
if (relation->rd_firstRelfilelocatorSubid == InvalidSubTransactionId)
relation->rd_firstRelfilelocatorSubid = relation->rd_newRelfilelocatorSubid;
+ elog(DEBUG1, "RelationAssumeNewRelfilelocator: oid %u %s",
+ RelationGetRelid(relation),
+ relpathperm(relation->rd_locator, MAIN_FORKNUM).str);
+
/* Flag relation as needing eoxact cleanup (to clear these fields) */
EOXactListAdd(relation);
}
diff --git a/src/test/regress/expected/tablespace_xact.out b/src/test/regress/expected/tablespace_xact.out
new file mode 100644
index 00000000000..85d3d35f332
--- /dev/null
+++ b/src/test/regress/expected/tablespace_xact.out
@@ -0,0 +1 @@
+-- place holder --
\ No newline at end of file
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 75063f87a4a..865bde70d5c 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -145,4 +145,4 @@ test: fast_default
# run tablespace test at the end because it drops the tablespace created during
# setup that other tests may use.
-test: tablespace
+test: tablespace tablespace_xact
diff --git a/src/test/regress/sql/tablespace_xact.sql b/src/test/regress/sql/tablespace_xact.sql
new file mode 100644
index 00000000000..43fc20c7505
--- /dev/null
+++ b/src/test/regress/sql/tablespace_xact.sql
@@ -0,0 +1,251 @@
+
+--
+-- Deferred heap copy for ALTER TABLE SET TABLESPACE on indexed tables
+--
+SET allow_in_place_tablespaces = true;
+SET client_min_messages = DEBUG1;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+
+CREATE SCHEMA tablespace_xact;
+SET search_path TO tablespace_xact;
+
+CREATE TABLESPACE xact_tblspace LOCATION '';
+CREATE TABLESPACE xact_tblspace2 LOCATION '';
+PREPARE check_tablespace AS
+ SELECT c.relname, coalesce(s.spcname, '(default)') AS tablespace
+ FROM pg_class c
+ LEFT JOIN pg_tablespace s ON s.oid = c.reltablespace
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'tablespace_xact'
+ ORDER BY 1;
+
+-- COMMIT
+CREATE TABLE defer_t (a int);
+EXECUTE check_tablespace;
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (3);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- ROLLBACK
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (3);
+ROLLBACK;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- COMMIT ENDING UP IN THE SAME SPACE
+
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE pg_default;
+INSERT INTO defer_t VALUES (4);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- PREPARE TRANSACTION, COMMIT PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_commit';
+COMMIT PREPARED 'defer_tblsp_prep_commit';
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- PREPARE TRANSACTION, ROLLBACK PREPARED
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace2;
+INSERT INTO defer_t VALUES (3);
+PREPARE TRANSACTION 'defer_tblsp_prep_rollback';
+ROLLBACK PREPARED 'defer_tblsp_prep_rollback';
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (4), (5);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: move in savepoint, subcommit, then top-level commit
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (6), (7);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: move in savepoint, then rollback to savepoint
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+ROLLBACK TO SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: subcommit move, then top-level rollback cancels move
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+RELEASE SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (5);
+ROLLBACK;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (6), (7), (8);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Subtransaction: two deferred moves in one transaction
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace2;
+INSERT INTO defer_t VALUES (6);
+COMMIT;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+CREATE TABLE defer_t (a int);
+CREATE INDEX defer_t_idx ON defer_t (a);
+INSERT INTO defer_t VALUES (1);
+BEGIN;
+INSERT INTO defer_t VALUES (2);
+SAVEPOINT sp1;
+INSERT INTO defer_t VALUES (3);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace;
+INSERT INTO defer_t VALUES (4);
+SAVEPOINT sp2;
+INSERT INTO defer_t VALUES (5);
+ALTER TABLE defer_t SET TABLESPACE xact_tblspace2;
+INSERT INTO defer_t VALUES (6);
+ROLLBACK;
+EXECUTE check_tablespace;
+INSERT INTO defer_t VALUES (7), (8), (9);
+SELECT ctid, a FROM defer_t ORDER BY a;
+DROP TABLE defer_t;
+
+-- Partitioned table: SET TABLESPACE in a transaction; new partitions use it
+CREATE TABLE defer_part (a int) PARTITION BY RANGE (a);
+CREATE TABLE defer_part_p0 PARTITION OF defer_part FOR VALUES FROM (0) TO (10);
+CREATE INDEX ON defer_part(a);
+
+INSERT INTO defer_part VALUES (1);
+EXECUTE check_tablespace;
+BEGIN;
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace;
+-- defer_part tablespace is updated, but partitions are not moved
+EXECUTE check_tablespace;
+-- new partitions created in the new table space
+CREATE TABLE defer_part_p1 PARTITION OF defer_part FOR VALUES FROM (10) TO (20);
+INSERT INTO defer_part VALUES (15);
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace2;
+CREATE TABLE defer_part_p2 PARTITION OF defer_part FOR VALUES FROM (20) TO (30);
+ALTER TABLE defer_part SET TABLESPACE pg_default;
+CREATE TABLE defer_part_p3 PARTITION OF defer_part FOR VALUES FROM (30) TO (40);
+EXECUTE check_tablespace;
+ROLLBACK; -- rollback delete partitions created in the transaction
+EXECUTE check_tablespace;
+
+-- Partitioned table: SET TABLESPACE in a transaction; new partitions use it
+CREATE TABLE defer_part (a int) PARTITION BY RANGE (a);
+CREATE TABLE defer_part_p0 PARTITION OF defer_part FOR VALUES FROM (0) TO (10);
+CREATE INDEX ON defer_part(a);
+INSERT INTO defer_part VALUES (1);
+EXECUTE check_tablespace;
+BEGIN;
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace;
+-- defer_part tablespace is updated, but partitions are not moved
+EXECUTE check_tablespace;
+-- new partitions created in the new table space
+CREATE TABLE defer_part_p1 PARTITION OF defer_part FOR VALUES FROM (10) TO (20);
+INSERT INTO defer_part VALUES (15);
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace2;
+CREATE TABLE defer_part_p2 PARTITION OF defer_part FOR VALUES FROM (20) TO (30);
+ALTER TABLE defer_part SET TABLESPACE pg_default;
+CREATE TABLE defer_part_p3 PARTITION OF defer_part FOR VALUES FROM (30) TO (40);
+EXECUTE check_tablespace;
+ROLLBACK; -- rollback delete partitions created in the transaction
+EXECUTE check_tablespace;
+
+BEGIN;
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace;
+CREATE TABLE defer_part_p1 PARTITION OF defer_part FOR VALUES FROM (10) TO (20);
+INSERT INTO defer_part VALUES (15);
+ALTER TABLE defer_part SET TABLESPACE xact_tblspace2;
+CREATE TABLE defer_part_p2 PARTITION OF defer_part FOR VALUES FROM (20) TO (30);
+ALTER TABLE defer_part SET TABLESPACE pg_default;
+CREATE TABLE defer_part_p3 PARTITION OF defer_part FOR VALUES FROM (30) TO (40);
+COMMIT; -- commit persists partitions created in the transaction
+EXECUTE check_tablespace;
+DROP TABLE defer_part;
+EXECUTE check_tablespace;
+
+DROP TABLESPACE xact_tblspace;
+DROP TABLESPACE xact_tblspace2;
+
+DROP SCHEMA CASCADE tablespace_xact;
\ No newline at end of file
--
2.53.0
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-29 21:44 Andres Freund <andres@anarazel.de>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
1 sibling, 1 reply; 33+ messages in thread
From: Andres Freund @ 2026-09-29 21:44 UTC (permalink / raw)
To: Alexandre Felipe <o.alexandre.felipe@gmail.com>; +Cc: PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi,
On 2026-09-16 23:06:34 +0100, Alexandre Felipe wrote:
> This patchset addresses the issue reported on the pgsql-bugs [1]
>
> The root cause is that after the relation files are copied to a new
> tablespace queries
> update the index in place but the heap is updated only in the tablespace
> copy. If
> the transaction is rolled back, the index and the heap becomes inconsistent.
>
> First I tried to fix the crash, easy for btree, manageable for hash, but
> for GiST that
> would not be feasible, AFAIK would have to perform array searches possibly
> over
> multiple pages. Later thinking about this I noticed something I didn't
> realise on my
> first read.
I think it'd also just hide corruption. That's definitely not the way to go.
> So, I decided to fix the root cause: modifying a non-durable copy of the
> file.
> I thought it would be way harder, but the code was architected well enough
> that I could save a list of deferred copies, and keep modifying the the
> table
> in place. If the transaction is rolled back all the tuples in the index
> will have
> its (possibly dead) in the heap, effectively reserving those TID, this
> prevents
> both the insertion of duplicates, and the resuscitation of dead tuples by
> later changes.
I don't think copying the file at commit is a good path, we shouldn't make
commits take arbitrarily long without pretty darn good reason. I don't think
this is that.
What about forcing indexes to be copied to a new relfilenode when copying the
underlying table?
Greetings,
Andres Freund
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-29 22:23 Zsolt Parragi <zsolt.parragi@percona.com>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
0 siblings, 0 replies; 33+ messages in thread
From: Zsolt Parragi @ 2026-09-29 22:23 UTC (permalink / raw)
To: Alexandre Felipe <o.alexandre.felipe@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, Manu <manuelreyesbravo@gmail.com>
Hello
> v3 adds a paragraph about deferred copy and clarifies that catalog
> updates might not be visible within the transaction, I think it is good
> that it reflects the physical location of the relation. Reading the
> preceding
> paragraph I decided that it was worth adding a test case for partitioned
> tables.
0004 is empty, the documentation is missing.
> 2. Failure to copy the files at the end of transaction, e.g. the new
> tablespace doesn't have enough space. Will the transaction roll back
> cleanly reclaiming the space?
The patch could keep at least the creation of the storage (directory)
at command time, and leave only the data copy to the end. That won't
help against out of space errors, but it could properly handle all
cases of permission / preexisting directory issues.
> My proposal was to do the other way, push the cost to the end of the
> transaction instead, even the heap copy, so if the transaction fails it will
> do no file copies. If the transaction completes, it will do exactly the same
> copies that upstream does, just in a different order. At least that is the
> rationale, maybe during review additional cases that I missed will come up.
This is only true when nothing modifies the relation between the ALTER
TABLESPACE and the COMMIT. When something does, the deferred copy
copies the post-modification relation, so every row written after the
move is written twice, once into the old file and once into the new by
the copy. This is especially visible with wal_level=minimal, where
previously we logged nothing and with the patch we log the entire
table in this case.
This doesn't seem fixable to me, but it also doesn't seem to be a
blocker for the patch/approach to me. If a user does things the other
way around, first modifying the relation and then doing alter, master
already does the same amount of writes, so it only affects one
specific order.
And some specific comments for the patches:
1.
+ /*
+ * Cluster moves by relation id, then for each relation apply the last
+ * change.
+ */
+ list_sort(deferred_tablespace_moves, deferred_tablespace_move_compare_relid);
and
+static int
+deferred_tablespace_move_compare_relid(const ListCell *a, const ListCell *b)
+{
+ PendingTablespaceMove *ca = (PendingTablespaceMove *) a;
+ PendingTablespaceMove *cb = (PendingTablespaceMove *) b;
+ int t = pg_cmp_u32(ca->relid, cb->relid);
+
+ if (t == 0) /* compare pointers for stability */
+ t = (a < b) ? -1 : 1;
+ return t;
+}
This comparator is wrong in multiple ways, see the comment for list_sort:
* The comparator function is declared to receive arguments of type
* const ListCell *; this allows it to use lfirst() and variants
* without casting its arguments. Otherwise it behaves the same as
* the comparator function for standard qsort().
*
* Like qsort(), this provides no guarantees about sort stability
* for equal keys.
The issue is also easily verifiable with an SQL reproducer:
BEGIN;
SAVEPOINT s;
ALTER TABLE b SET TABLESPACE regress_ts1;
ALTER TABLE b SET TABLESPACE pg_default;
ROLLBACK TO s;
ALTER TABLE a SET TABLESPACE regress_ts1;
ALTER TABLE a SET TABLESPACE pg_default;
COMMIT;
Do we need sort at all here? Why can't the code walk the list, and
resolve the last entry per relid?
2. dropping a relation with a pending move now makes COMMIT fail with
an internal error
CREATE TABLESPACE regress_ts1 LOCATION '';
CREATE TABLE d1(a int primary key);
BEGIN; ALTER TABLE d1 SET TABLESPACE regress_ts1; DROP TABLE d1; COMMIT;
Skipping entries whose relation no longer exists seems safe to me.
3. tablespace_xact.sql fails with max_prepared_transaction = 0
this is a test only issue, but should be fixed, this causes a clear CI failure
Another issue with it is the naming of the tablespaces, those should
start with regress_:
+CREATE TABLESPACE xact_tblspace LOCATION '';
+CREATE TABLESPACE xact_tblspace2 LOCATION '';
See ENFORCE_REGRESSION_TEST_NAME_RESTRICTIONS
4. ALTER TABLE ALL IN TABLESPACE acts on a stale catalog
This is another visible effect of the staleness question that was
already discussed.
SET allow_in_place_tablespaces = on;
CREATE TABLESPACE ts1 LOCATION '';
CREATE TABLESPACE ts2 LOCATION '';
CREATE TABLE t (a int PRIMARY KEY) TABLESPACE ts1;
BEGIN;
ALTER TABLE t SET TABLESPACE pg_default;
ALTER TABLE ALL IN TABLESPACE ts1 SET TABLESPACE ts2;
COMMIT;
SELECT coalesce(spcname, 'pg_default') AS tablespace
FROM pg_class c LEFT JOIN pg_tablespace s ON s.oid = c.reltablespace
WHERE c.relname = 't';
And it seems to me that ALTER TABLE ALL IN TABLESPACE could follow the
intent of the transaction based on the pending list.
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-29 23:39 Tom Lane <tgl@sss.pgh.pa.us>
parent: Andres Freund <andres@anarazel.de>
0 siblings, 2 replies; 33+ messages in thread
From: Tom Lane @ 2026-09-29 23:39 UTC (permalink / raw)
To: Andres Freund <andres@anarazel.de>; +Cc: Alexandre Felipe <o.alexandre.felipe@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Andres Freund <andres@anarazel.de> writes:
> I don't think copying the file at commit is a good path, we shouldn't make
> commits take arbitrarily long without pretty darn good reason. I don't think
> this is that.
+1. Moving operations with a significant probability of failure into
COMMIT is also an anti-pattern.
> What about forcing indexes to be copied to a new relfilenode when copying the
> underlying table?
That seems like the logical solution to me. Nobody will be surprised
if ALTER SET TABLESPACE takes a long time for a big table; at least
not if they understand that it requires copying the data somewhere
else. Imposing costs at COMMIT time might well surprise people.
The one disadvantage I see is that (I imagine) a common use-case is
to move both a table and its indexes to a new tablespace, and this
solution will imply that that sequence double-copies the indexes.
Maybe it'd be worth providing a command variant that copies the
table and its indexes to a new tablespace in one step. But that
is a future optimization, not part of the bug fix; and I could be
wrong about whether anyone even cares.
regards, tom lane
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-30 00:08 Michael Paquier <michael@paquier.xyz>
parent: Tom Lane <tgl@sss.pgh.pa.us>
1 sibling, 1 reply; 33+ messages in thread
From: Michael Paquier @ 2026-09-30 00:08 UTC (permalink / raw)
To: Tom Lane <tgl@sss.pgh.pa.us>; +Cc: Andres Freund <andres@anarazel.de>; Alexandre Felipe <o.alexandre.felipe@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
On Tue, Sep 29, 2026 at 07:39:40PM -0400, Tom Lane wrote:
> That seems like the logical solution to me. Nobody will be surprised
> if ALTER SET TABLESPACE takes a long time for a big table; at least
> not if they understand that it requires copying the data somewhere
> else. Imposing costs at COMMIT time might well surprise people.
Yes, putting the cost within the ALTER TABLE would feel less
surprising.
> The one disadvantage I see is that (I imagine) a common use-case is
> to move both a table and its indexes to a new tablespace, and this
> solution will imply that that sequence double-copies the indexes.
> Maybe it'd be worth providing a command variant that copies the
> table and its indexes to a new tablespace in one step. But that
> is a future optimization, not part of the bug fix; and I could be
> wrong about whether anyone even cares.
And it's something that would qualify as a new feature, not really
backpatchable. At least it feels risky compared to the idea of
copying the index relfilenode.
--
Michael
Attachments:
[application/pgp-signature] signature.asc (832B, ../../arxTGn_8Fb9JLM0e@paquier.xyz/2-signature.asc)
download
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-30 07:23 Alexandre Felipe <o.alexandre.felipe@gmail.com>
parent: Michael Paquier <michael@paquier.xyz>
0 siblings, 1 reply; 33+ messages in thread
From: Alexandre Felipe @ 2026-09-30 07:23 UTC (permalink / raw)
To: Michael Paquier <michael@paquier.xyz>; +Cc: Tom Lane <tgl@sss.pgh.pa.us>; Andres Freund <andres@anarazel.de>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Thank you all.
Was good to see so many reviews in such a short time.
Given that the expected behaviour is to copy only the heap.
Indexes get corrupted on modifications.
We shouldn't push the cost to commit.
What if we simply block modifications to the table in a transaction
after it moved to a new tablespace?
Regards,
Alexandre
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-30 13:40 Tom Lane <tgl@sss.pgh.pa.us>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Tom Lane @ 2026-09-30 13:40 UTC (permalink / raw)
To: Alexandre Felipe <o.alexandre.felipe@gmail.com>; +Cc: Michael Paquier <michael@paquier.xyz>; Andres Freund <andres@anarazel.de>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Alexandre Felipe <o.alexandre.felipe@gmail.com> writes:
> What if we simply block modifications to the table in a transaction
> after it moved to a new tablespace?
If we were looking for a quick-n-dirty functionality-losing patch,
we'd just reject ALTER SET TABLESPACE within transaction blocks.
Perhaps that's the right answer for the back branches, but
I'd prefer not to go that way.
It seems to me that there is consensus among the senior hackers
who have looked at this about what to do. I'm not sure why you
are pushing for a more complex and more risky answer.
regards, tom lane
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-09-30 14:16 Alexandre Felipe <o.alexandre.felipe@gmail.com>
parent: Tom Lane <tgl@sss.pgh.pa.us>
0 siblings, 1 reply; 33+ messages in thread
From: Alexandre Felipe @ 2026-09-30 14:16 UTC (permalink / raw)
To: Tom Lane <tgl@sss.pgh.pa.us>; +Cc: Michael Paquier <michael@paquier.xyz>; Andres Freund <andres@anarazel.de>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Thank you Tom,
On Wed, Sep 30, 2026 at 2:40 PM Tom Lane <tgl@sss.pgh.pa.us> wrote:
> Alexandre Felipe <o.alexandre.felipe@gmail.com> writes:
> > What if we simply block modifications to the table in a transaction
> > after it moved to a new tablespace?
>
> If we were looking for a quick-n-dirty functionality-losing patch,
> we'd just reject ALTER SET TABLESPACE within transaction blocks.
> Perhaps that's the right answer for the back branches, but
> I'd prefer not to go that way.
>
Noted, but could you please address the question directly.
Currently ALTER SET TABLESPACE already locks the tables,
blocking changes to the old file, this prevents corrupted indices on
committed transactions.
What I suggested was to mirror that restriction inside the transaction,
i.e. you can't modify the table in the new tablespace during the
transaction.
We lose one feature but one could even argue that it makes the
behaviour more consistent in some sense.
It seems to me that there is consensus among the senior hackers
>
I see, there is a consensus that deferring file copies is
not a good trade-off and I am not pushing for that.
Regards,
Alexandre Felipe
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-01 08:20 Alexandre Felipe <o.alexandre.felipe@gmail.com>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Alexandre Felipe @ 2026-10-01 08:20 UTC (permalink / raw)
To: Tom Lane <tgl@sss.pgh.pa.us>; +Cc: Michael Paquier <michael@paquier.xyz>; Andres Freund <andres@anarazel.de>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
*Indexes on the table, if any, are not moved; but they can be moved*
*separately with additional SET TABLESPACE commands.* [1]
On Tue, Sep 29, 2026 at 10:44 PM Andres Freund <andres@anarazel.de> wrote:
> What about forcing indexes to be copied to a new relfilenode when copying
> the
> underlying table?
That would be slower ...
On Wed, Sep 30, 2026 at 1:09 AM Michael Paquier <michael@paquier.xyz> wrote:
> Yes, putting the cost within the ALTER TABLE would feel less
> surprising.
If I am moving to a different tablespace, chances are that the current
tablespace
is too full, and adding files there would possibly make it worse. It would
make
sense to take up space in the target tablespace not the source.
Manu,
This comment only exist after the patch, and talk about how things work
before the patch.
+ /*
+ * Moving a table's heap assigns it a new relfilenode, but its indexes are
+ * deliberately left in place with their existing relfilenodes. That mix
+ * is unsafe across a rollback: if the transaction inserts into the table
+ * after this and then aborts, the heap's new relfilenode is discarded and
+ * its file reverts to the pre-move contents, freeing the TIDs used by the
+ * aborted rows; but the matching index entries were written to the
+ * unchanged index files and survive the abort. A later insert can reuse a
+ * freed heap TID, leaving two index entries pointing at the same live heap
+ * tuple -- index corruption (bug #19686). Give each index a fresh
+ * relfilenumber, copied within its own tablespace, so it shares the heap's
+ * new-relfilenode fate: on abort the new heap and index files are all
+ * discarded together, and on commit they are all kept.
+ */
And you need test cases.
In that case it seems that it is better to go forward with your approach,
and I am stepping down as an author.
[1]
https://www.postgresql.org/docs/18/sql-altertable.html#SQL-ALTERTABLE-DESC-SET-TABLESPACE
Attachments:
[application/octet-stream] v4-copy-indices-on-alter-table-set-tablespace.patch (6.6K, ../../CAE8JnxNDuZzkQ7T9T+qZQU5Z7Ptfmwet7rkDggbRAT3hBio4UA@mail.gmail.com/3-v4-copy-indices-on-alter-table-set-tablespace.patch)
download | inline diff:
From 3c5662ff268ac2b2e803130b7199517160f5a4e9 Mon Sep 17 00:00:00 2001
From: Manu <manuelreyesbravo@gmail.com>
Date: Sun, 27 Sep 2026 13:08:36 -0300
Subject: [PATCH] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but deliberately leaves the table's indexes on their
existing relfilenodes. The two then roll back by different mechanisms:
on abort the heap's new file is discarded, so the heap TIDs consumed by
rows inserted after the SET TABLESPACE become free again, while the
index entries for those rows were written to the unchanged index files
and survive the abort. A later insert can reuse a freed heap TID,
leaving two index entries that point at the same live heap tuple. This
surfaces as a _bt_posting_valid assertion failure in nbtree
deduplication, and as duplicate rows through an index-only scan in gist.
Make the indexes share the heap's rewrite: after moving the heap, give
each of the table's indexes a new relfilenumber by copying it within its
own tablespace, so that an abort discards the new heap and index files
together and a commit keeps them together.
Bug: #19686
Reported-by: Alexander Lakhin
---
src/backend/commands/tablecmds.c | 93 ++++++++++++++++++++++++++++++++
1 file changed, 93 insertions(+)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0274d892f2e..918d517ac23 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -695,6 +695,7 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -17520,9 +17521,11 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -17540,6 +17543,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -17586,6 +17590,13 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW)
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -17599,6 +17610,88 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * Moving a table's heap assigns it a new relfilenode, but its indexes are
+ * deliberately left in place with their existing relfilenodes. That mix
+ * is unsafe across a rollback: if the transaction inserts into the table
+ * after this and then aborts, the heap's new relfilenode is discarded and
+ * its file reverts to the pre-move contents, freeing the TIDs used by the
+ * aborted rows; but the matching index entries were written to the
+ * unchanged index files and survive the abort. A later insert can reuse a
+ * freed heap TID, leaving two index entries pointing at the same live heap
+ * tuple -- index corruption (bug #19686). Give each index a fresh
+ * relfilenumber, copied within its own tablespace, so it shares the heap's
+ * new-relfilenode fate: on abort the new heap and index files are all
+ * discarded together, and on commit they are all kept.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share that rewrite's
+ * transactional fate, otherwise an abort discards the heap's new file (freeing
+ * its TIDs) while leaving behind index entries that were written during the
+ * transaction, which a later insert can then alias -- index corruption (bug
+ * #19686). Unlike a full reindex this merely copies the existing index file,
+ * so the added cost stays close to that of the heap move itself.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /* Only plain indexes have storage that can hold the stale entries. */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind))
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
--
2.55.0
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 00:57 Manu <manuelreyesbravo@gmail.com>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Manu @ 2026-10-02 00:57 UTC (permalink / raw)
To: Alexandre Felipe <o.alexandre.felipe@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org
Hi Alexandre,
Thanks -- and for going with this approach. You raised two things: the
comment still described the pre-patch behavior, and it needed test
cases. Both are addressed in the attached v2:
- The comment no longer narrates the pre-patch behavior. It now says,
in the present tense, that the heap has just been rewritten and the
indexes must share that fate, and leaves the detail to
ATExecSetTableSpaceNewIndexRelfilenumber.
- There is a regression test now, in the tablespace suite. It runs
the rollback-then-reinsert sequence and checks, with seqscans off,
that the index agrees with the single live heap row -- it returns
two against the bug, where the aborted transaction's index entry
survives and aliases the reused TID. check-world is green with the
patch.
The approach is the one we settled on: after the heap move, each of the
table's indexes gets a fresh relfilenumber copied within its own
tablespace, so an abort discards the new heap and index files together.
It does not change the indexes' tablespace, matching the documented
behavior that SET TABLESPACE leaves indexes in place.
Regards,
Manu
Attachments:
[text/x-patch] v2-0001-Fix-index-corruption-SET-TABLESPACE-rollback.patch (9.5K, ../../179090264869.85105.1086434870759405279@gmail.com/2-v2-0001-Fix-index-corruption-SET-TABLESPACE-rollback.patch)
download | inline diff:
From a7758293e3bb4c36b93571435c60dfd23294f324 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Thu, 1 Oct 2026 21:37:04 -0300
Subject: [PATCH] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist.
After moving the heap, give each of the table's indexes a new
relfilenumber by copying it within its own tablespace, so that an abort
discards the new heap and index files together and a commit keeps them
together. This does not change the indexes' tablespace, matching the
documented behavior that SET TABLESPACE leaves indexes in place.
Add a regression test to the tablespace suite.
Bug: #19686
Reported-by: Alexander Lakhin
---
src/backend/commands/tablecmds.c | 86 ++++++++++++++++++++++++
src/test/regress/expected/tablespace.out | 25 +++++++
src/test/regress/sql/tablespace.sql | 21 ++++++
3 files changed, 132 insertions(+)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..2de51df9fa8 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -701,6 +701,7 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -17530,9 +17531,11 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -17550,6 +17553,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -17596,6 +17600,13 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW)
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -17609,6 +17620,81 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort; otherwise a rolled-back insert can leave an
+ * index entry that a later insert's reused heap TID aliases -- index
+ * corruption (bug #19686). See ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share that rewrite's
+ * transactional fate, otherwise an abort discards the heap's new file (freeing
+ * its TIDs) while leaving behind index entries that were written during the
+ * transaction, which a later insert can then alias -- index corruption (bug
+ * #19686). Unlike a full reindex this merely copies the existing index file,
+ * so the added cost stays close to that of the heap move itself.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /* Only plain indexes have storage that can hold the stale entries. */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind))
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..7d7215555ad 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,31 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- bug #19686: rolling back ALTER TABLE SET TABLESPACE must not leave the
+-- table's indexes referencing heap TIDs that the aborted heap rewrite frees.
+-- The indexes have to share the heap's rewrite, so that a later insert cannot
+-- reuse a freed TID that a surviving index entry still points at.
+CREATE TABLE tbspace_19686 (a int);
+CREATE INDEX tbspace_19686_idx ON tbspace_19686 (a);
+BEGIN;
+ALTER TABLE tbspace_19686 SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_19686 VALUES (0);
+ROLLBACK;
+INSERT INTO tbspace_19686 VALUES (0);
+-- With seqscans disabled this count is answered from the index; it must match
+-- the single live heap row (it returns 2 against the bug, where the aborted
+-- transaction's index entry survives and aliases the reused TID).
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_19686 WHERE a = 0;
+ count
+-------
+ 1
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_19686;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..0f79a2708a3 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,27 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- bug #19686: rolling back ALTER TABLE SET TABLESPACE must not leave the
+-- table's indexes referencing heap TIDs that the aborted heap rewrite frees.
+-- The indexes have to share the heap's rewrite, so that a later insert cannot
+-- reuse a freed TID that a surviving index entry still points at.
+CREATE TABLE tbspace_19686 (a int);
+CREATE INDEX tbspace_19686_idx ON tbspace_19686 (a);
+BEGIN;
+ALTER TABLE tbspace_19686 SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_19686 VALUES (0);
+ROLLBACK;
+INSERT INTO tbspace_19686 VALUES (0);
+-- With seqscans disabled this count is answered from the index; it must match
+-- the single live heap row (it returns 2 against the bug, where the aborted
+-- transaction's index entry survives and aliases the reused TID).
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_19686 WHERE a = 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_19686;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 03:27 shihao zhong <zhong950419@gmail.com>
parent: Manu <manuelreyesbravo@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: shihao zhong @ 2026-10-02 03:27 UTC (permalink / raw)
To: Manu <manuelreyesbravo@gmail.com>; +Cc: Alexandre Felipe <o.alexandre.felipe@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi Manu,
I reviewed v2 and it looks correct. I asked Fable to test rollback,
savepoints, 2PC, crash recovery and a standby, and they all pass. The
test fails without the fix.
v2 also fixes SET TABLESPACE, TRUNCATE, ROLLBACK, which empties the
index on master.
Alexandre's full tablespace case does fail with v2. Moving the
indexes first works.
It applies to 16 and up. 14 and 15 need the old RelFileNode names.
Thanks,
Shihao
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 08:35 Alexandre Felipe <o.alexandre.felipe@gmail.com>
parent: shihao zhong <zhong950419@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Alexandre Felipe @ 2026-10-02 08:35 UTC (permalink / raw)
To: shihao zhong <zhong950419@gmail.com>; +Cc: Manu <manuelreyesbravo@gmail.com>; pgsql-hackers@lists.postgresql.org
Thank you Manu,
Again talking about past,
I am insisting on this point, I don't see the codebase talking about past
bugs,
the test just describes the correct behaviour.
+-- With seqscans disabled this count is answered from the index; it must
match
+-- the single live heap row (it returns 2 against the bug, where the
aborted
+-- transaction's index entry survives and aliases the reused TID).
If we are recreating the indices, a better way to observe the effect
is by inserting enough data to grow one page inside the transaction
and checking pg_relation_size before and after the rollback.
All that story about visibility map, reindexing, vacuuming indices,
or imagine that in the future we have another way to satisfy the query
without the indices.
+CREATE TABLE tbspace_19686 (a int);
Do we need to keep that ticket number here? Maybe in the commit message.
That suffix impairs readability in my opinion.
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
This is very counterintuitive,
moving to a different tablespace should not take up more space in the
original tablespace.
> If one is moving to a different tablespace, chances are that the current
tablespace
> is too full, and adding files there would possibly make it worse. It
would make
> sense to take up space in the target tablespace not the source.
Regards,
Alexandre
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 14:36 Manu <manuelreyesbravo@gmail.com>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Manu @ 2026-10-02 14:36 UTC (permalink / raw)
To: Alexandre Felipe <o.alexandre.felipe@gmail.com>; +Cc: shihao zhong <zhong950419@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi Alexandre, Shihao,
Thanks to both. v4 attached; it changes the approach because of
Alexandre's last point, so let me take that one first.
> moving to a different tablespace should not take up more space in the
> original tablespace.
Agreed, and v4 does not. The indexes only need to follow the heap's
rewrite if the table is modified again in the same transaction, which
is also the only way the corruption can happen. So v4 leaves SET
TABLESPACE itself alone and gives an index a new relfilenumber (copied
within its own tablespace, so the indexes stay where the documentation
says) when the executor opens it to modify a table whose storage was
replaced in the current transaction, and before an in-place TRUNCATE of
such a table -- the case Shihao found. The ALTER alone in its
transaction copies nothing.
Measured with a 162 MB table and 188 MB of indexes in a nearly full
source tablespace: the move succeeds, the source needs no extra space
(v3 needed the size of the indexes there), the target receives the
162 MB heap, and the ALTER takes 0.13 s instead of 0.20 s. Modifying
the table in the same transaction after the move still needs room for
the index copies; with the source full that fails with ENOSPC at the
INSERT, cleanly (relfilenodes unchanged, nothing left behind beyond the
usual zero-length files until the next checkpoint, amcheck passes, same
after a restart).
The hook is one call in ExecOpenIndices and one in the in-place branch
of TRUNCATE. It keys on rd_firstRelfilelocatorSubid, which says whether
a relation's storage differs from what it was at the start of the top
transaction and is kept accurate for RelationNeedsWAL().
> I don't see the codebase talking about past bugs,
> the test just describes the correct behaviour.
Agreed. The comments and the test describe the behavior only; the bug
number is in the commit message.
> If we are recreating the indices, a better way to observe the effect
> is by inserting enough data to grow one page inside the transaction
> and checking pg_relation_size before and after the rollback.
Done, and it is a better test: on master the index goes from 16384 to
65536 bytes and stays there after the rollback; with the fix it is back
to 16384. The test also checks that the move alone leaves the index
file untouched, the same across a subtransaction, and the TRUNCATE
case.
> Do we need to keep that ticket number here?
Renamed to tbspace_rollback.
Shihao: thanks for the broader tests. Patches for REL_15 and REL_14
(old RelFileNode names; in 14 the test lives in tablespace.source) are
attached; each passes the regression suite on its branch.
Regards,
Manu
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 17:06 Alexandre Felipe <o.alexandre.felipe@gmail.com>
parent: Manu <manuelreyesbravo@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Alexandre Felipe @ 2026-10-02 17:06 UTC (permalink / raw)
To: Manu <manuelreyesbravo@gmail.com>; +Cc: shihao zhong <zhong950419@gmail.com>; pgsql-hackers@lists.postgresql.org
Thank you Manu,
was v4 supposed to be attached?
On Fri, Oct 2, 2026 at 3:36 PM Manu <manuelreyesbravo@gmail.com> wrote:
> Hi Alexandre, Shihao,
>
> Thanks to both. v4 attached; it changes the approach because of
> Alexandre's last point, so let me take that one first.
>
> > moving to a different tablespace should not take up more space in the
> > original tablespace.
>
> Agreed, and v4 does not. The indexes only need to follow the heap's
> rewrite if the table is modified again in the same transaction, which
> is also the only way the corruption can happen. So v4 leaves SET
> TABLESPACE itself alone and gives an index a new relfilenumber (copied
> within its own tablespace, so the indexes stay where the documentation
> says) when the executor opens it to modify a table whose storage was
> replaced in the current transaction, and before an in-place TRUNCATE of
> such a table -- the case Shihao found. The ALTER alone in its
> transaction copies nothing.
>
> Measured with a 162 MB table and 188 MB of indexes in a nearly full
> source tablespace: the move succeeds, the source needs no extra space
> (v3 needed the size of the indexes there), the target receives the
> 162 MB heap, and the ALTER takes 0.13 s instead of 0.20 s. Modifying
> the table in the same transaction after the move still needs room for
> the index copies; with the source full that fails with ENOSPC at the
> INSERT, cleanly (relfilenodes unchanged, nothing left behind beyond the
> usual zero-length files until the next checkpoint, amcheck passes, same
> after a restart).
>
> The hook is one call in ExecOpenIndices and one in the in-place branch
> of TRUNCATE. It keys on rd_firstRelfilelocatorSubid, which says whether
> a relation's storage differs from what it was at the start of the top
> transaction and is kept accurate for RelationNeedsWAL().
>
> > I don't see the codebase talking about past bugs,
> > the test just describes the correct behaviour.
>
> Agreed. The comments and the test describe the behavior only; the bug
> number is in the commit message.
>
> > If we are recreating the indices, a better way to observe the effect
> > is by inserting enough data to grow one page inside the transaction
> > and checking pg_relation_size before and after the rollback.
>
> Done, and it is a better test: on master the index goes from 16384 to
> 65536 bytes and stays there after the rollback; with the fix it is back
> to 16384. The test also checks that the move alone leaves the index
> file untouched, the same across a subtransaction, and the TRUNCATE
> case.
>
> > Do we need to keep that ticket number here?
>
> Renamed to tbspace_rollback.
>
> Shihao: thanks for the broader tests. Patches for REL_15 and REL_14
> (old RelFileNode names; in 14 the test lives in tablespace.source) are
> attached; each passes the regression suite on its branch.
>
> Regards,
> Manu
>
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 17:18 Manu <manuelreyesbravo@gmail.com>
parent: Alexandre Felipe <o.alexandre.felipe@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Manu @ 2026-10-02 17:18 UTC (permalink / raw)
To: Alexandre Felipe <o.alexandre.felipe@gmail.com>; +Cc: shihao zhong <zhong950419@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi Alexandre,
> was v4 supposed to be attached?
Yes, sorry: my sending script dropped them. Attached now: master
(applies on 16+), REL_15 and REL_14.
Manu
From cfa6e309b053319684f9b7ab22173e603dc69916 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 11:16:32 -0300
Subject: [PATCH v4] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction has a similar problem: it truncates the old index files in
place, which a rollback cannot undo.
The indexes only need to share the heap's rewrite when the table is
modified again in the same transaction, which is also the only time the
corruption can arise. So leave SET TABLESPACE itself alone, and give an
index a new relfilenumber -- copying it within its own tablespace, as
documented, the indexes stay where they are -- when the executor opens
it to modify a table whose storage was replaced in the current
transaction, and before an in-place TRUNCATE of such a table. The ALTER
alone in its transaction, the common case, copies nothing and needs no
extra space where the indexes live.
The regression test checks that the move alone leaves the index file
untouched, and that after inserting enough rows inside the transaction
to grow the index, with and without a subtransaction, or truncating the
table, a rollback leaves the index file at its pre-transaction size.
Bug: #19686
Reported-by: Alexander Lakhin
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 93 ++++++++++++++++++++++++
src/backend/executor/execIndexing.c | 9 +++
src/include/commands/tablecmds.h | 1 +
src/test/regress/expected/tablespace.out | 65 +++++++++++++++++
src/test/regress/sql/tablespace.sql | 36 +++++++++
5 files changed, 204 insertions(+)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..4f771cbbe9f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -2241,6 +2241,24 @@ ExecuteTruncateGuts(List *explicit_rels,
if (rel->rd_createSubid == mySubid ||
rel->rd_newRelfilelocatorSubid == mySubid)
{
+ List *indexIds = RelationGetIndexList(rel);
+ ListCell *ic;
+
+ /*
+ * The in-place truncation below also truncates the indexes in
+ * place. An index still on the file it had before this
+ * transaction (SET TABLESPACE leaves them so) must get a new one
+ * first, or a rollback could not restore it.
+ */
+ foreach(ic, indexIds)
+ {
+ Relation indexRel = index_open(lfirst_oid(ic), AccessExclusiveLock);
+
+ IndexFollowHeapRelfilelocator(rel, indexRel);
+ index_close(indexRel, NoLock);
+ }
+ list_free(indexIds);
+
/* Immediate, non-rollbackable truncation is OK */
heap_truncate_one_rel(rel);
}
@@ -17877,6 +17895,81 @@ index_copy_data(Relation rel, RelFileLocator newrlocator)
smgrclose(dstrel);
}
+/*
+ * IndexFollowHeapRelfilelocator
+ *
+ * ALTER TABLE SET TABLESPACE gives a table's heap a new relfilenumber but
+ * leaves the table's indexes on their old files, since the indexes are
+ * documented to stay where they are. That mix is fine until the table is
+ * modified again in the same transaction: on abort the heap's new file is
+ * discarded, freeing the heap TIDs used by rows inserted after the move,
+ * while the index entries for those rows were written to the old index files
+ * and survive, so a later insert can reuse a freed TID and leave two index
+ * entries pointing at one live tuple.
+ *
+ * So, before an index is modified (or truncated in place) in a transaction
+ * that replaced its heap's storage, give the index a new relfilenumber too,
+ * copying it within its own tablespace: an abort then discards the new heap
+ * and index files together. Doing this here rather than in SET TABLESPACE
+ * itself keeps the common case -- the ALTER alone in its transaction --
+ * free of any copy, so it needs no extra space where the indexes live.
+ *
+ * rd_firstRelfilelocatorSubid says whether a relation's storage differs from
+ * what it was at the start of the top transaction; it is kept accurate for
+ * RelationNeedsWAL(). An index created, or given a new file, in this
+ * transaction already shares the heap's fate and is left alone.
+ */
+void
+IndexFollowHeapRelfilelocator(Relation heapRel, Relation indexRel)
+{
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ if (heapRel->rd_firstRelfilelocatorSubid == InvalidSubTransactionId ||
+ indexRel->rd_firstRelfilelocatorSubid != InvalidSubTransactionId ||
+ indexRel->rd_createSubid != InvalidSubTransactionId)
+ return;
+
+ /* Only plain indexes have storage that can hold the stale entries. */
+ if (indexRel->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(indexRel->rd_rel->relkind))
+ return;
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(indexRel->rd_rel->reltablespace, NULL,
+ indexRel->rd_rel->relpersistence);
+ newrlocator = indexRel->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(indexRel, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID,
+ ObjectIdGetDatum(RelationGetRelid(indexRel)));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u",
+ RelationGetRelid(indexRel));
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(indexRel), 0);
+ RelationAssumeNewRelfilelocator(indexRel);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
+}
+
/*
* ALTER TABLE ENABLE/DISABLE TRIGGER
*
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index eb383812901..b9695146363 100644
--- a/src/backend/executor/execIndexing.c
+++ b/src/backend/executor/execIndexing.c
@@ -111,6 +111,7 @@
#include "access/tableam.h"
#include "access/xact.h"
#include "catalog/index.h"
+#include "commands/tablecmds.h"
#include "executor/executor.h"
#include "nodes/nodeFuncs.h"
#include "storage/lmgr.h"
@@ -212,6 +213,14 @@ ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative)
indexDesc = index_open(indexOid, RowExclusiveLock);
+ /*
+ * If the table's storage was replaced earlier in this transaction
+ * while the index kept its old file (ALTER TABLE SET TABLESPACE moves
+ * the heap only), the index must get a new file too before we write
+ * to it, so that an abort discards both together.
+ */
+ IndexFollowHeapRelfilelocator(resultRelation, indexDesc);
+
/* extract index key information from the index's pg_index info */
ii = BuildIndexInfo(indexDesc);
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index c3d8518cb62..dfbe4e4a775 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -69,6 +69,7 @@ extern void ExecuteTruncateGuts(List *explicit_rels,
extern void SetRelationHasSubclass(Oid relationId, bool relhassubclass);
extern bool CheckRelationTableSpaceMove(Relation rel, Oid newTableSpaceId);
+extern void IndexFollowHeapRelfilelocator(Relation heapRel, Relation indexRel);
extern void SetRelationTableSpace(Relation rel, Oid newTableSpaceId,
RelFileNumber newRelFilenumber);
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..282956a8f62 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,71 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+ idx_untouched
+---------------
+ t
+(1 row)
+
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+DROP TABLE tbspace_rollback;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..c711b0cbacc 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,42 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+DROP TABLE tbspace_rollback;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From a68d8b540543b7db45749b407ab19e5c6d0c5f59 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 11:20:23 -0300
Subject: [PATCH] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction has a similar problem: it truncates the old index files in
place, which a rollback cannot undo.
The indexes only need to share the heap's rewrite when the table is
modified again in the same transaction, which is also the only time the
corruption can arise. So leave SET TABLESPACE itself alone, and give an
index a new relfilenumber -- copying it within its own tablespace, as
documented, the indexes stay where they are -- when the executor opens
it to modify a table whose storage was replaced in the current
transaction, and before an in-place TRUNCATE of such a table. The ALTER
alone in its transaction, the common case, copies nothing and needs no
extra space where the indexes live.
The regression test checks that the move alone leaves the index file
untouched, and that after inserting enough rows inside the transaction
to grow the index, with and without a subtransaction, or truncating the
table, a rollback leaves the index file at its pre-transaction size.
Bug: #19686
Reported-by: Alexander Lakhin
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 93 ++++++++++++++++++++++++
src/backend/executor/execIndexing.c | 9 +++
src/include/commands/tablecmds.h | 1 +
src/test/regress/expected/tablespace.out | 65 +++++++++++++++++
src/test/regress/sql/tablespace.sql | 36 +++++++++
5 files changed, 204 insertions(+)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..db7a58f065e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -2018,6 +2018,24 @@ ExecuteTruncateGuts(List *explicit_rels,
if (rel->rd_createSubid == mySubid ||
rel->rd_newRelfilenodeSubid == mySubid)
{
+ List *indexIds = RelationGetIndexList(rel);
+ ListCell *ic;
+
+ /*
+ * The in-place truncation below also truncates the indexes in
+ * place. An index still on the file it had before this
+ * transaction (SET TABLESPACE leaves them so) must get a new one
+ * first, or a rollback could not restore it.
+ */
+ foreach(ic, indexIds)
+ {
+ Relation indexRel = index_open(lfirst_oid(ic), AccessExclusiveLock);
+
+ IndexFollowHeapRelfilenode(rel, indexRel);
+ index_close(indexRel, NoLock);
+ }
+ list_free(indexIds);
+
/* Immediate, non-rollbackable truncation is OK */
heap_truncate_one_rel(rel);
}
@@ -15138,6 +15156,81 @@ index_copy_data(Relation rel, RelFileNode newrnode)
smgrclose(dstrel);
}
+/*
+ * IndexFollowHeapRelfilenode
+ *
+ * ALTER TABLE SET TABLESPACE gives a table's heap a new relfilenode but
+ * leaves the table's indexes on their old files, since the indexes are
+ * documented to stay where they are. That mix is fine until the table is
+ * modified again in the same transaction: on abort the heap's new file is
+ * discarded, freeing the heap TIDs used by rows inserted after the move,
+ * while the index entries for those rows were written to the old index files
+ * and survive, so a later insert can reuse a freed TID and leave two index
+ * entries pointing at one live tuple.
+ *
+ * So, before an index is modified (or truncated in place) in a transaction
+ * that replaced its heap's storage, give the index a new relfilenode too,
+ * copying it within its own tablespace: an abort then discards the new heap
+ * and index files together. Doing this here rather than in SET TABLESPACE
+ * itself keeps the common case -- the ALTER alone in its transaction --
+ * free of any copy, so it needs no extra space where the indexes live.
+ *
+ * rd_firstRelfilenodeSubid says whether a relation's storage differs from
+ * what it was at the start of the top transaction; it is kept accurate for
+ * RelationNeedsWAL(). An index created, or given a new file, in this
+ * transaction already shares the heap's fate and is left alone.
+ */
+void
+IndexFollowHeapRelfilenode(Relation heapRel, Relation indexRel)
+{
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ if (heapRel->rd_firstRelfilenodeSubid == InvalidSubTransactionId ||
+ indexRel->rd_firstRelfilenodeSubid != InvalidSubTransactionId ||
+ indexRel->rd_createSubid != InvalidSubTransactionId)
+ return;
+
+ /* Only plain indexes have storage that can hold the stale entries. */
+ if (indexRel->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(indexRel->rd_rel->relkind))
+ return;
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(indexRel->rd_rel->reltablespace, NULL,
+ indexRel->rd_rel->relpersistence);
+ newrnode = indexRel->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(indexRel, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID,
+ ObjectIdGetDatum(RelationGetRelid(indexRel)));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u",
+ RelationGetRelid(indexRel));
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(indexRel), 0);
+ RelationAssumeNewRelfilenode(indexRel);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
+}
+
/*
* ALTER TABLE ENABLE/DISABLE TRIGGER
*
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index ce9d04186bc..075caee50c5 100644
--- a/src/backend/executor/execIndexing.c
+++ b/src/backend/executor/execIndexing.c
@@ -111,6 +111,7 @@
#include "access/tableam.h"
#include "access/xact.h"
#include "catalog/index.h"
+#include "commands/tablecmds.h"
#include "executor/executor.h"
#include "nodes/nodeFuncs.h"
#include "storage/lmgr.h"
@@ -203,6 +204,14 @@ ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative)
indexDesc = index_open(indexOid, RowExclusiveLock);
+ /*
+ * If the table's storage was replaced earlier in this transaction
+ * while the index kept its old file (ALTER TABLE SET TABLESPACE moves
+ * the heap only), the index must get a new file too before we write
+ * to it, so that an abort discards both together.
+ */
+ IndexFollowHeapRelfilenode(resultRelation, indexDesc);
+
/* extract index key information from the index's pg_index info */
ii = BuildIndexInfo(indexDesc);
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index 5d4037f26e8..6437421022d 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -65,6 +65,7 @@ extern void ExecuteTruncateGuts(List *explicit_rels,
extern void SetRelationHasSubclass(Oid relationId, bool relhassubclass);
extern bool CheckRelationTableSpaceMove(Relation rel, Oid newTableSpaceId);
+extern void IndexFollowHeapRelfilenode(Relation heapRel, Relation indexRel);
extern void SetRelationTableSpace(Relation rel, Oid newTableSpaceId,
Oid newRelFileNode);
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..8d5b45bb4e5 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,71 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+ idx_untouched
+---------------
+ t
+(1 row)
+
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+DROP TABLE tbspace_rollback;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..4fa0256fa80 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,42 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+DROP TABLE tbspace_rollback;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From 292a8d1dbbd8bd4295a2da2a455853cffb1cb192 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 11:20:23 -0300
Subject: [PATCH] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction has a similar problem: it truncates the old index files in
place, which a rollback cannot undo.
The indexes only need to share the heap's rewrite when the table is
modified again in the same transaction, which is also the only time the
corruption can arise. So leave SET TABLESPACE itself alone, and give an
index a new relfilenumber -- copying it within its own tablespace, as
documented, the indexes stay where they are -- when the executor opens
it to modify a table whose storage was replaced in the current
transaction, and before an in-place TRUNCATE of such a table. The ALTER
alone in its transaction, the common case, copies nothing and needs no
extra space where the indexes live.
The regression test checks that the move alone leaves the index file
untouched, and that after inserting enough rows inside the transaction
to grow the index, with and without a subtransaction, or truncating the
table, a rollback leaves the index file at its pre-transaction size.
Bug: #19686
Reported-by: Alexander Lakhin
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 93 +++++++++++++++++++++++
src/backend/executor/execIndexing.c | 9 +++
src/include/commands/tablecmds.h | 1 +
src/test/regress/input/tablespace.source | 36 +++++++++
src/test/regress/output/tablespace.source | 65 ++++++++++++++++
5 files changed, 204 insertions(+)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..eee129916bd 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -1977,6 +1977,24 @@ ExecuteTruncateGuts(List *explicit_rels,
if (rel->rd_createSubid == mySubid ||
rel->rd_newRelfilenodeSubid == mySubid)
{
+ List *indexIds = RelationGetIndexList(rel);
+ ListCell *ic;
+
+ /*
+ * The in-place truncation below also truncates the indexes in
+ * place. An index still on the file it had before this
+ * transaction (SET TABLESPACE leaves them so) must get a new one
+ * first, or a rollback could not restore it.
+ */
+ foreach(ic, indexIds)
+ {
+ Relation indexRel = index_open(lfirst_oid(ic), AccessExclusiveLock);
+
+ IndexFollowHeapRelfilenode(rel, indexRel);
+ index_close(indexRel, NoLock);
+ }
+ list_free(indexIds);
+
/* Immediate, non-rollbackable truncation is OK */
heap_truncate_one_rel(rel);
}
@@ -14547,6 +14565,81 @@ index_copy_data(Relation rel, RelFileNode newrnode)
smgrclose(dstrel);
}
+/*
+ * IndexFollowHeapRelfilenode
+ *
+ * ALTER TABLE SET TABLESPACE gives a table's heap a new relfilenode but
+ * leaves the table's indexes on their old files, since the indexes are
+ * documented to stay where they are. That mix is fine until the table is
+ * modified again in the same transaction: on abort the heap's new file is
+ * discarded, freeing the heap TIDs used by rows inserted after the move,
+ * while the index entries for those rows were written to the old index files
+ * and survive, so a later insert can reuse a freed TID and leave two index
+ * entries pointing at one live tuple.
+ *
+ * So, before an index is modified (or truncated in place) in a transaction
+ * that replaced its heap's storage, give the index a new relfilenode too,
+ * copying it within its own tablespace: an abort then discards the new heap
+ * and index files together. Doing this here rather than in SET TABLESPACE
+ * itself keeps the common case -- the ALTER alone in its transaction --
+ * free of any copy, so it needs no extra space where the indexes live.
+ *
+ * rd_firstRelfilenodeSubid says whether a relation's storage differs from
+ * what it was at the start of the top transaction; it is kept accurate for
+ * RelationNeedsWAL(). An index created, or given a new file, in this
+ * transaction already shares the heap's fate and is left alone.
+ */
+void
+IndexFollowHeapRelfilenode(Relation heapRel, Relation indexRel)
+{
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ if (heapRel->rd_firstRelfilenodeSubid == InvalidSubTransactionId ||
+ indexRel->rd_firstRelfilenodeSubid != InvalidSubTransactionId ||
+ indexRel->rd_createSubid != InvalidSubTransactionId)
+ return;
+
+ /* Only plain indexes have storage that can hold the stale entries. */
+ if (indexRel->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(indexRel->rd_rel->relkind))
+ return;
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(indexRel->rd_rel->reltablespace, NULL,
+ indexRel->rd_rel->relpersistence);
+ newrnode = indexRel->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(indexRel, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID,
+ ObjectIdGetDatum(RelationGetRelid(indexRel)));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u",
+ RelationGetRelid(indexRel));
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(indexRel), 0);
+ RelationAssumeNewRelfilenode(indexRel);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
+}
+
/*
* ALTER TABLE ENABLE/DISABLE TRIGGER
*
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index 19118051412..4157be96045 100644
--- a/src/backend/executor/execIndexing.c
+++ b/src/backend/executor/execIndexing.c
@@ -111,6 +111,7 @@
#include "access/tableam.h"
#include "access/xact.h"
#include "catalog/index.h"
+#include "commands/tablecmds.h"
#include "executor/executor.h"
#include "nodes/nodeFuncs.h"
#include "storage/lmgr.h"
@@ -198,6 +199,14 @@ ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative)
indexDesc = index_open(indexOid, RowExclusiveLock);
+ /*
+ * If the table's storage was replaced earlier in this transaction
+ * while the index kept its old file (ALTER TABLE SET TABLESPACE moves
+ * the heap only), the index must get a new file too before we write
+ * to it, so that an abort discards both together.
+ */
+ IndexFollowHeapRelfilenode(resultRelation, indexDesc);
+
/* extract index key information from the index's pg_index info */
ii = BuildIndexInfo(indexDesc);
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index 336549cc5f0..d6f24eab2d7 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -65,6 +65,7 @@ extern void ExecuteTruncateGuts(List *explicit_rels,
extern void SetRelationHasSubclass(Oid relationId, bool relhassubclass);
extern bool CheckRelationTableSpaceMove(Relation rel, Oid newTableSpaceId);
+extern void IndexFollowHeapRelfilenode(Relation heapRel, Relation indexRel);
extern void SetRelationTableSpace(Relation rel, Oid newTableSpaceId,
Oid newRelFileNode);
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..3e05e82143a 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,42 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+DROP TABLE tbspace_rollback;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..2418b85d4e4 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,71 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+ idx_untouched
+---------------
+ t
+(1 row)
+
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+DROP TABLE tbspace_rollback;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
Attachments:
[text/plain] us-ascii''v4-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (13.4K, ../../179096148059.372335.2710841757388164497@gmail.com/2-us-ascii%27%27v4-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From cfa6e309b053319684f9b7ab22173e603dc69916 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 11:16:32 -0300
Subject: [PATCH v4] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction has a similar problem: it truncates the old index files in
place, which a rollback cannot undo.
The indexes only need to share the heap's rewrite when the table is
modified again in the same transaction, which is also the only time the
corruption can arise. So leave SET TABLESPACE itself alone, and give an
index a new relfilenumber -- copying it within its own tablespace, as
documented, the indexes stay where they are -- when the executor opens
it to modify a table whose storage was replaced in the current
transaction, and before an in-place TRUNCATE of such a table. The ALTER
alone in its transaction, the common case, copies nothing and needs no
extra space where the indexes live.
The regression test checks that the move alone leaves the index file
untouched, and that after inserting enough rows inside the transaction
to grow the index, with and without a subtransaction, or truncating the
table, a rollback leaves the index file at its pre-transaction size.
Bug: #19686
Reported-by: Alexander Lakhin
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 93 ++++++++++++++++++++++++
src/backend/executor/execIndexing.c | 9 +++
src/include/commands/tablecmds.h | 1 +
src/test/regress/expected/tablespace.out | 65 +++++++++++++++++
src/test/regress/sql/tablespace.sql | 36 +++++++++
5 files changed, 204 insertions(+)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..4f771cbbe9f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -2241,6 +2241,24 @@ ExecuteTruncateGuts(List *explicit_rels,
if (rel->rd_createSubid == mySubid ||
rel->rd_newRelfilelocatorSubid == mySubid)
{
+ List *indexIds = RelationGetIndexList(rel);
+ ListCell *ic;
+
+ /*
+ * The in-place truncation below also truncates the indexes in
+ * place. An index still on the file it had before this
+ * transaction (SET TABLESPACE leaves them so) must get a new one
+ * first, or a rollback could not restore it.
+ */
+ foreach(ic, indexIds)
+ {
+ Relation indexRel = index_open(lfirst_oid(ic), AccessExclusiveLock);
+
+ IndexFollowHeapRelfilelocator(rel, indexRel);
+ index_close(indexRel, NoLock);
+ }
+ list_free(indexIds);
+
/* Immediate, non-rollbackable truncation is OK */
heap_truncate_one_rel(rel);
}
@@ -17877,6 +17895,81 @@ index_copy_data(Relation rel, RelFileLocator newrlocator)
smgrclose(dstrel);
}
+/*
+ * IndexFollowHeapRelfilelocator
+ *
+ * ALTER TABLE SET TABLESPACE gives a table's heap a new relfilenumber but
+ * leaves the table's indexes on their old files, since the indexes are
+ * documented to stay where they are. That mix is fine until the table is
+ * modified again in the same transaction: on abort the heap's new file is
+ * discarded, freeing the heap TIDs used by rows inserted after the move,
+ * while the index entries for those rows were written to the old index files
+ * and survive, so a later insert can reuse a freed TID and leave two index
+ * entries pointing at one live tuple.
+ *
+ * So, before an index is modified (or truncated in place) in a transaction
+ * that replaced its heap's storage, give the index a new relfilenumber too,
+ * copying it within its own tablespace: an abort then discards the new heap
+ * and index files together. Doing this here rather than in SET TABLESPACE
+ * itself keeps the common case -- the ALTER alone in its transaction --
+ * free of any copy, so it needs no extra space where the indexes live.
+ *
+ * rd_firstRelfilelocatorSubid says whether a relation's storage differs from
+ * what it was at the start of the top transaction; it is kept accurate for
+ * RelationNeedsWAL(). An index created, or given a new file, in this
+ * transaction already shares the heap's fate and is left alone.
+ */
+void
+IndexFollowHeapRelfilelocator(Relation heapRel, Relation indexRel)
+{
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ if (heapRel->rd_firstRelfilelocatorSubid == InvalidSubTransactionId ||
+ indexRel->rd_firstRelfilelocatorSubid != InvalidSubTransactionId ||
+ indexRel->rd_createSubid != InvalidSubTransactionId)
+ return;
+
+ /* Only plain indexes have storage that can hold the stale entries. */
+ if (indexRel->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(indexRel->rd_rel->relkind))
+ return;
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(indexRel->rd_rel->reltablespace, NULL,
+ indexRel->rd_rel->relpersistence);
+ newrlocator = indexRel->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(indexRel, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID,
+ ObjectIdGetDatum(RelationGetRelid(indexRel)));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u",
+ RelationGetRelid(indexRel));
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(indexRel), 0);
+ RelationAssumeNewRelfilelocator(indexRel);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
+}
+
/*
* ALTER TABLE ENABLE/DISABLE TRIGGER
*
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index eb383812901..b9695146363 100644
--- a/src/backend/executor/execIndexing.c
+++ b/src/backend/executor/execIndexing.c
@@ -111,6 +111,7 @@
#include "access/tableam.h"
#include "access/xact.h"
#include "catalog/index.h"
+#include "commands/tablecmds.h"
#include "executor/executor.h"
#include "nodes/nodeFuncs.h"
#include "storage/lmgr.h"
@@ -212,6 +213,14 @@ ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative)
indexDesc = index_open(indexOid, RowExclusiveLock);
+ /*
+ * If the table's storage was replaced earlier in this transaction
+ * while the index kept its old file (ALTER TABLE SET TABLESPACE moves
+ * the heap only), the index must get a new file too before we write
+ * to it, so that an abort discards both together.
+ */
+ IndexFollowHeapRelfilelocator(resultRelation, indexDesc);
+
/* extract index key information from the index's pg_index info */
ii = BuildIndexInfo(indexDesc);
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index c3d8518cb62..dfbe4e4a775 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -69,6 +69,7 @@ extern void ExecuteTruncateGuts(List *explicit_rels,
extern void SetRelationHasSubclass(Oid relationId, bool relhassubclass);
extern bool CheckRelationTableSpaceMove(Relation rel, Oid newTableSpaceId);
+extern void IndexFollowHeapRelfilelocator(Relation heapRel, Relation indexRel);
extern void SetRelationTableSpace(Relation rel, Oid newTableSpaceId,
RelFileNumber newRelFilenumber);
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..282956a8f62 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,71 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+ idx_untouched
+---------------
+ t
+(1 row)
+
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+DROP TABLE tbspace_rollback;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..c711b0cbacc 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,42 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+DROP TABLE tbspace_rollback;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v4-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (13.3K, ../../179096148059.372335.2710841757388164497@gmail.com/3-us-ascii%27%27v4-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From a68d8b540543b7db45749b407ab19e5c6d0c5f59 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 11:20:23 -0300
Subject: [PATCH] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction has a similar problem: it truncates the old index files in
place, which a rollback cannot undo.
The indexes only need to share the heap's rewrite when the table is
modified again in the same transaction, which is also the only time the
corruption can arise. So leave SET TABLESPACE itself alone, and give an
index a new relfilenumber -- copying it within its own tablespace, as
documented, the indexes stay where they are -- when the executor opens
it to modify a table whose storage was replaced in the current
transaction, and before an in-place TRUNCATE of such a table. The ALTER
alone in its transaction, the common case, copies nothing and needs no
extra space where the indexes live.
The regression test checks that the move alone leaves the index file
untouched, and that after inserting enough rows inside the transaction
to grow the index, with and without a subtransaction, or truncating the
table, a rollback leaves the index file at its pre-transaction size.
Bug: #19686
Reported-by: Alexander Lakhin
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 93 ++++++++++++++++++++++++
src/backend/executor/execIndexing.c | 9 +++
src/include/commands/tablecmds.h | 1 +
src/test/regress/expected/tablespace.out | 65 +++++++++++++++++
src/test/regress/sql/tablespace.sql | 36 +++++++++
5 files changed, 204 insertions(+)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..db7a58f065e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -2018,6 +2018,24 @@ ExecuteTruncateGuts(List *explicit_rels,
if (rel->rd_createSubid == mySubid ||
rel->rd_newRelfilenodeSubid == mySubid)
{
+ List *indexIds = RelationGetIndexList(rel);
+ ListCell *ic;
+
+ /*
+ * The in-place truncation below also truncates the indexes in
+ * place. An index still on the file it had before this
+ * transaction (SET TABLESPACE leaves them so) must get a new one
+ * first, or a rollback could not restore it.
+ */
+ foreach(ic, indexIds)
+ {
+ Relation indexRel = index_open(lfirst_oid(ic), AccessExclusiveLock);
+
+ IndexFollowHeapRelfilenode(rel, indexRel);
+ index_close(indexRel, NoLock);
+ }
+ list_free(indexIds);
+
/* Immediate, non-rollbackable truncation is OK */
heap_truncate_one_rel(rel);
}
@@ -15138,6 +15156,81 @@ index_copy_data(Relation rel, RelFileNode newrnode)
smgrclose(dstrel);
}
+/*
+ * IndexFollowHeapRelfilenode
+ *
+ * ALTER TABLE SET TABLESPACE gives a table's heap a new relfilenode but
+ * leaves the table's indexes on their old files, since the indexes are
+ * documented to stay where they are. That mix is fine until the table is
+ * modified again in the same transaction: on abort the heap's new file is
+ * discarded, freeing the heap TIDs used by rows inserted after the move,
+ * while the index entries for those rows were written to the old index files
+ * and survive, so a later insert can reuse a freed TID and leave two index
+ * entries pointing at one live tuple.
+ *
+ * So, before an index is modified (or truncated in place) in a transaction
+ * that replaced its heap's storage, give the index a new relfilenode too,
+ * copying it within its own tablespace: an abort then discards the new heap
+ * and index files together. Doing this here rather than in SET TABLESPACE
+ * itself keeps the common case -- the ALTER alone in its transaction --
+ * free of any copy, so it needs no extra space where the indexes live.
+ *
+ * rd_firstRelfilenodeSubid says whether a relation's storage differs from
+ * what it was at the start of the top transaction; it is kept accurate for
+ * RelationNeedsWAL(). An index created, or given a new file, in this
+ * transaction already shares the heap's fate and is left alone.
+ */
+void
+IndexFollowHeapRelfilenode(Relation heapRel, Relation indexRel)
+{
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ if (heapRel->rd_firstRelfilenodeSubid == InvalidSubTransactionId ||
+ indexRel->rd_firstRelfilenodeSubid != InvalidSubTransactionId ||
+ indexRel->rd_createSubid != InvalidSubTransactionId)
+ return;
+
+ /* Only plain indexes have storage that can hold the stale entries. */
+ if (indexRel->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(indexRel->rd_rel->relkind))
+ return;
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(indexRel->rd_rel->reltablespace, NULL,
+ indexRel->rd_rel->relpersistence);
+ newrnode = indexRel->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(indexRel, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID,
+ ObjectIdGetDatum(RelationGetRelid(indexRel)));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u",
+ RelationGetRelid(indexRel));
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(indexRel), 0);
+ RelationAssumeNewRelfilenode(indexRel);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
+}
+
/*
* ALTER TABLE ENABLE/DISABLE TRIGGER
*
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index ce9d04186bc..075caee50c5 100644
--- a/src/backend/executor/execIndexing.c
+++ b/src/backend/executor/execIndexing.c
@@ -111,6 +111,7 @@
#include "access/tableam.h"
#include "access/xact.h"
#include "catalog/index.h"
+#include "commands/tablecmds.h"
#include "executor/executor.h"
#include "nodes/nodeFuncs.h"
#include "storage/lmgr.h"
@@ -203,6 +204,14 @@ ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative)
indexDesc = index_open(indexOid, RowExclusiveLock);
+ /*
+ * If the table's storage was replaced earlier in this transaction
+ * while the index kept its old file (ALTER TABLE SET TABLESPACE moves
+ * the heap only), the index must get a new file too before we write
+ * to it, so that an abort discards both together.
+ */
+ IndexFollowHeapRelfilenode(resultRelation, indexDesc);
+
/* extract index key information from the index's pg_index info */
ii = BuildIndexInfo(indexDesc);
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index 5d4037f26e8..6437421022d 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -65,6 +65,7 @@ extern void ExecuteTruncateGuts(List *explicit_rels,
extern void SetRelationHasSubclass(Oid relationId, bool relhassubclass);
extern bool CheckRelationTableSpaceMove(Relation rel, Oid newTableSpaceId);
+extern void IndexFollowHeapRelfilenode(Relation heapRel, Relation indexRel);
extern void SetRelationTableSpace(Relation rel, Oid newTableSpaceId,
Oid newRelFileNode);
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..8d5b45bb4e5 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,71 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+ idx_untouched
+---------------
+ t
+(1 row)
+
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+DROP TABLE tbspace_rollback;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..4fa0256fa80 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,42 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+DROP TABLE tbspace_rollback;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v4-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (13.3K, ../../179096148059.372335.2710841757388164497@gmail.com/4-us-ascii%27%27v4-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 292a8d1dbbd8bd4295a2da2a455853cffb1cb192 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 11:20:23 -0300
Subject: [PATCH] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction has a similar problem: it truncates the old index files in
place, which a rollback cannot undo.
The indexes only need to share the heap's rewrite when the table is
modified again in the same transaction, which is also the only time the
corruption can arise. So leave SET TABLESPACE itself alone, and give an
index a new relfilenumber -- copying it within its own tablespace, as
documented, the indexes stay where they are -- when the executor opens
it to modify a table whose storage was replaced in the current
transaction, and before an in-place TRUNCATE of such a table. The ALTER
alone in its transaction, the common case, copies nothing and needs no
extra space where the indexes live.
The regression test checks that the move alone leaves the index file
untouched, and that after inserting enough rows inside the transaction
to grow the index, with and without a subtransaction, or truncating the
table, a rollback leaves the index file at its pre-transaction size.
Bug: #19686
Reported-by: Alexander Lakhin
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 93 +++++++++++++++++++++++
src/backend/executor/execIndexing.c | 9 +++
src/include/commands/tablecmds.h | 1 +
src/test/regress/input/tablespace.source | 36 +++++++++
src/test/regress/output/tablespace.source | 65 ++++++++++++++++
5 files changed, 204 insertions(+)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..eee129916bd 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -1977,6 +1977,24 @@ ExecuteTruncateGuts(List *explicit_rels,
if (rel->rd_createSubid == mySubid ||
rel->rd_newRelfilenodeSubid == mySubid)
{
+ List *indexIds = RelationGetIndexList(rel);
+ ListCell *ic;
+
+ /*
+ * The in-place truncation below also truncates the indexes in
+ * place. An index still on the file it had before this
+ * transaction (SET TABLESPACE leaves them so) must get a new one
+ * first, or a rollback could not restore it.
+ */
+ foreach(ic, indexIds)
+ {
+ Relation indexRel = index_open(lfirst_oid(ic), AccessExclusiveLock);
+
+ IndexFollowHeapRelfilenode(rel, indexRel);
+ index_close(indexRel, NoLock);
+ }
+ list_free(indexIds);
+
/* Immediate, non-rollbackable truncation is OK */
heap_truncate_one_rel(rel);
}
@@ -14547,6 +14565,81 @@ index_copy_data(Relation rel, RelFileNode newrnode)
smgrclose(dstrel);
}
+/*
+ * IndexFollowHeapRelfilenode
+ *
+ * ALTER TABLE SET TABLESPACE gives a table's heap a new relfilenode but
+ * leaves the table's indexes on their old files, since the indexes are
+ * documented to stay where they are. That mix is fine until the table is
+ * modified again in the same transaction: on abort the heap's new file is
+ * discarded, freeing the heap TIDs used by rows inserted after the move,
+ * while the index entries for those rows were written to the old index files
+ * and survive, so a later insert can reuse a freed TID and leave two index
+ * entries pointing at one live tuple.
+ *
+ * So, before an index is modified (or truncated in place) in a transaction
+ * that replaced its heap's storage, give the index a new relfilenode too,
+ * copying it within its own tablespace: an abort then discards the new heap
+ * and index files together. Doing this here rather than in SET TABLESPACE
+ * itself keeps the common case -- the ALTER alone in its transaction --
+ * free of any copy, so it needs no extra space where the indexes live.
+ *
+ * rd_firstRelfilenodeSubid says whether a relation's storage differs from
+ * what it was at the start of the top transaction; it is kept accurate for
+ * RelationNeedsWAL(). An index created, or given a new file, in this
+ * transaction already shares the heap's fate and is left alone.
+ */
+void
+IndexFollowHeapRelfilenode(Relation heapRel, Relation indexRel)
+{
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ if (heapRel->rd_firstRelfilenodeSubid == InvalidSubTransactionId ||
+ indexRel->rd_firstRelfilenodeSubid != InvalidSubTransactionId ||
+ indexRel->rd_createSubid != InvalidSubTransactionId)
+ return;
+
+ /* Only plain indexes have storage that can hold the stale entries. */
+ if (indexRel->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(indexRel->rd_rel->relkind))
+ return;
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(indexRel->rd_rel->reltablespace, NULL,
+ indexRel->rd_rel->relpersistence);
+ newrnode = indexRel->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(indexRel, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID,
+ ObjectIdGetDatum(RelationGetRelid(indexRel)));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u",
+ RelationGetRelid(indexRel));
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(indexRel), 0);
+ RelationAssumeNewRelfilenode(indexRel);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
+}
+
/*
* ALTER TABLE ENABLE/DISABLE TRIGGER
*
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index 19118051412..4157be96045 100644
--- a/src/backend/executor/execIndexing.c
+++ b/src/backend/executor/execIndexing.c
@@ -111,6 +111,7 @@
#include "access/tableam.h"
#include "access/xact.h"
#include "catalog/index.h"
+#include "commands/tablecmds.h"
#include "executor/executor.h"
#include "nodes/nodeFuncs.h"
#include "storage/lmgr.h"
@@ -198,6 +199,14 @@ ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative)
indexDesc = index_open(indexOid, RowExclusiveLock);
+ /*
+ * If the table's storage was replaced earlier in this transaction
+ * while the index kept its old file (ALTER TABLE SET TABLESPACE moves
+ * the heap only), the index must get a new file too before we write
+ * to it, so that an abort discards both together.
+ */
+ IndexFollowHeapRelfilenode(resultRelation, indexDesc);
+
/* extract index key information from the index's pg_index info */
ii = BuildIndexInfo(indexDesc);
diff --git a/src/include/commands/tablecmds.h b/src/include/commands/tablecmds.h
index 336549cc5f0..d6f24eab2d7 100644
--- a/src/include/commands/tablecmds.h
+++ b/src/include/commands/tablecmds.h
@@ -65,6 +65,7 @@ extern void ExecuteTruncateGuts(List *explicit_rels,
extern void SetRelationHasSubclass(Oid relationId, bool relhassubclass);
extern bool CheckRelationTableSpaceMove(Relation rel, Oid newTableSpaceId);
+extern void IndexFollowHeapRelfilenode(Relation heapRel, Relation indexRel);
extern void SetRelationTableSpace(Relation rel, Oid newTableSpaceId,
Oid newRelFileNode);
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..3e05e82143a 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,42 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+DROP TABLE tbspace_rollback;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..2418b85d4e4 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,71 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap only; the indexes keep their files
+-- unless the table is modified later in the same transaction, in which case
+-- they get new files too, so that a rollback discards all of them together.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path_before \gset
+-- the move alone leaves the index file untouched
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path_before' AS idx_untouched;
+ idx_untouched
+---------------
+ t
+(1 row)
+
+-- rows inserted after the move: enough to grow the index by at least a page
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same across a subtransaction
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SAVEPOINT s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK TO s;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- TRUNCATE after the move is done in place; it must not truncate the old file
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+DROP TABLE tbspace_rollback;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 18:30 Andres Freund <andres@anarazel.de>
parent: Tom Lane <tgl@sss.pgh.pa.us>
1 sibling, 1 reply; 33+ messages in thread
From: Andres Freund @ 2026-10-02 18:30 UTC (permalink / raw)
To: Tom Lane <tgl@sss.pgh.pa.us>; +Cc: Alexandre Felipe <o.alexandre.felipe@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Hi,
On 2026-09-29 19:39:40 -0400, Tom Lane wrote:
> > What about forcing indexes to be copied to a new relfilenode when copying the
> > underlying table?
>
> That seems like the logical solution to me. Nobody will be surprised
> if ALTER SET TABLESPACE takes a long time for a big table; at least
> not if they understand that it requires copying the data somewhere
> else. Imposing costs at COMMIT time might well surprise people.
I wonder if we should try to apply two optimizations, even in the back
branches:
1) don't copy indexes if the SET TABLESPACE is executed at the top-level
I think most of the time that is what one should do anyway (to avoid holding
too many locks at once etc), and it'd give folks that are negatively
affected a way out.
I guess it could theoretically be possible to write to an index from an
event trigger and then trigger an abort? But event triggers are a superuser
only facility, and at some point a superuser gets to keep the pieces if they
are intent on breaking stuff.
2) Avoid the index copy if the index has been created in the current
subtransaction.
I don't think there's a danger of corruption in that case, since the
relfilenode of the index would be thrown away anyway, if the SET TABLESPACE
rolls back.
> The one disadvantage I see is that (I imagine) a common use-case is
> to move both a table and its indexes to a new tablespace, and this
> solution will imply that that sequence double-copies the indexes.
> Maybe it'd be worth providing a command variant that copies the
> table and its indexes to a new tablespace in one step. But that
> is a future optimization, not part of the bug fix; and I could be
> wrong about whether anyone even cares.
With the 2) from above, that could then be achieved by having a transaction
first move the indexes and then the table itself. Probably not as good as a
command doing both, but it can be done without a new syntax...q
Greetings,
Andres Freund
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 18:52 Andres Freund <andres@anarazel.de>
parent: Manu <manuelreyesbravo@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Andres Freund @ 2026-10-02 18:52 UTC (permalink / raw)
To: Manu <manuelreyesbravo@gmail.com>; +Cc: Alexandre Felipe <o.alexandre.felipe@gmail.com>; shihao zhong <zhong950419@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi,
On 2026-10-02 14:18:00 -0300, Manu wrote:
> The indexes only need to share the heap's rewrite when the table is
> modified again in the same transaction, which is also the only time the
> corruption can arise. So leave SET TABLESPACE itself alone, and give an
> index a new relfilenumber -- copying it within its own tablespace, as
> documented, the indexes stay where they are -- when the executor opens
> it to modify a table whose storage was replaced in the current
> transaction
I'm pretty doubtful that is a good idea. For one it'll make the performance
effects very hard to understand. Having a single insert suddenly taking an
hour will confuse folks rather profoundly.
I also suspect there might be some correctness issues, because you could have
nested queries where the outer query has an in-progress index scan or such and
then a query within e.g. a plpgsql function does an insert. The outer query
might have some buffers of the "old" index pinned etc, which certainly would
not make it safe to just move the relfilenode underneath.
Greetings,
Andres Freund
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 18:55 Tom Lane <tgl@sss.pgh.pa.us>
parent: Andres Freund <andres@anarazel.de>
0 siblings, 0 replies; 33+ messages in thread
From: Tom Lane @ 2026-10-02 18:55 UTC (permalink / raw)
To: Andres Freund <andres@anarazel.de>; +Cc: Alexandre Felipe <o.alexandre.felipe@gmail.com>; PostgreSQL Hackers <pgsql-hackers@lists.postgresql.org>
Andres Freund <andres@anarazel.de> writes:
> I wonder if we should try to apply two optimizations, even in the back
> branches:
> 1) don't copy indexes if the SET TABLESPACE is executed at the top-level
> 2) Avoid the index copy if the index has been created in the current
> subtransaction.
+1 to both things, doesn't seem too difficult. (1) probably covers
the majority of existing usage --- else, we'd have noticed this
problem a long time ago.
>> The one disadvantage I see is that (I imagine) a common use-case is
>> to move both a table and its indexes to a new tablespace, and this
>> solution will imply that that sequence double-copies the indexes.
>> Maybe it'd be worth providing a command variant that copies the
>> table and its indexes to a new tablespace in one step. But that
>> is a future optimization, not part of the bug fix; and I could be
>> wrong about whether anyone even cares.
> With the 2) from above, that could then be achieved by having a transaction
> first move the indexes and then the table itself. Probably not as good as a
> command doing both, but it can be done without a new syntax...q
Agreed, that at least provides a workaround.
regards, tom lane
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-02 19:34 Manu <manuelreyesbravo@gmail.com>
parent: Andres Freund <andres@anarazel.de>
0 siblings, 1 reply; 33+ messages in thread
From: Manu @ 2026-10-02 19:34 UTC (permalink / raw)
To: Andres Freund <andres@anarazel.de>; +Cc: Tom Lane <tgl@sss.pgh.pa.us>; Alexandre Felipe <o.alexandre.felipe@gmail.com>; shihao zhong <zhong950419@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi,
> I'm pretty doubtful that is a good idea. For one it'll make the performance
> effects very hard to understand.
> The outer query might have some buffers of the "old" index pinned etc
Agreed on both, so I've dropped the deferred copy. v5, attached, goes
back to copying the indexes in the ALTER, as v3 did, with the two
optimizations from your other mail, which Tom +1'd:
1) No copy when the ALTER is a top-level statement outside a transaction
block. AlterTableUtilityContext gets an isTopLevel field for that. Two
things can still write to the table in that transaction after the
ALTER, and v5 guards against both. A ddl_command_end event trigger: the
shortcut is off when one exists, which is cheap to check. A later
statement of an extended-protocol pipeline, which shares the transaction
until Sync: v5 sets XACT_FLAGS_NEEDIMMEDIATECOMMIT, as
PreventInTransactionBlock does, so a pipelined ALTER ... SET TABLESPACE
now commits on its own. With the guards removed, each case leaves 50
stale index entries; with v5, none.
2) No copy for an index whose current file was created in the current
subtransaction. It has to be the file's subtransaction, not the
transaction: an index created, reindexed or already copied before a
savepoint survives a rollback to it, and needs the copy. This also
covers your workaround for Tom's case: moving the indexes first and then
the table copies each index once. The regression test checks both.
For the back branches, isTopLevel goes at the end of
AlterTableUtilityContext; core is the only place I found that fills it.
make check passes on master, REL_15 and REL_14 (243, 217 and 216
tests). I also ran randomized transactions (nested savepoints, table
and index moves, REINDEX, DML, TRUNCATE, ROLLBACK TO, top-level moves),
comparing an index scan with a seq scan after each one: 40,000 with v5
and no mismatch, while the same test finds the bug on master. Results
are the same on a streaming standby, after an immediate-mode crash, and
with wal_level=minimal. The nested-query case can't come up now: the
ALTER refuses a table with an open scan ("being used by active
queries").
Regards,
Manu
From f818dfd16cd2121b8b85f919d03ea494dd43f6e0 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Thu, 1 Oct 2026 21:37:04 -0300
Subject: [PATCH v5] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a top-level statement outside a transaction block, so
nothing can modify the table before the transaction ends. This
requires that no ddl_command_end event trigger runs after it, and the
commit is forced right after the statement, as for
PreventInTransactionBlock, so that a later statement of an
extended-protocol pipeline cannot share the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that in a transaction block a rollback after inserting
or truncating leaves the index file at its pre-transaction size; that an
index created or moved earlier in the subtransaction is not copied; and
that after a rollback to a savepoint, an index created, reindexed or
already copied before the savepoint returns no row for the rolled-back
values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 132 ++++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 120 +++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 76 +++++++++++++
5 files changed, 325 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..dcfbb362c73 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -98,6 +98,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -700,7 +701,10 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -6097,7 +6101,8 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(context));
}
/*
@@ -17526,13 +17531,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -17550,6 +17558,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -17596,6 +17605,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -17603,12 +17620,117 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Nothing can when the ALTER is a top-level statement outside any
+ * transaction block and nothing runs after it before the commit: no
+ * ddl_command_end event trigger, and no further statement of an
+ * extended-protocol pipeline, which would otherwise share the transaction
+ * until the next Sync. To rule out the latter, force the commit right after
+ * the ALTER, as PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 4d33fcb5e9d..57d88f371ef 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1315,6 +1315,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index abbdb401bf6..0357550da71 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..ccc43061aad 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,126 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..8fc94894491 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,82 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From 12d170ec0f1ad3eed71030b205b9bd4d42c72644 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:47:53 -0300
Subject: [PATCH v5] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a top-level statement outside a transaction block, so
nothing can modify the table before the transaction ends. This
requires that no ddl_command_end event trigger runs after it, and the
commit is forced right after the statement, as for
PreventInTransactionBlock, so that a later statement of an
extended-protocol pipeline cannot share the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that in a transaction block a rollback after inserting
or truncating leaves the index file at its pre-transaction size; that an
index created or moved earlier in the subtransaction is not copied; and
that after a rollback to a savepoint, an index created, reindexed or
already copied before the savepoint returns no row for the rolled-back
values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 132 ++++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 120 +++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 76 +++++++++++++
5 files changed, 325 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..ea4245406c3 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -593,7 +594,10 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5670,7 +5674,8 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(context));
}
/*
@@ -14791,13 +14796,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14815,6 +14823,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14861,6 +14870,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14868,12 +14885,117 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Nothing can when the ALTER is a top-level statement outside any
+ * transaction block and nothing runs after it before the commit: no
+ * ddl_command_end event trigger, and no further statement of an
+ * extended-protocol pipeline, which would otherwise share the transaction
+ * until the next Sync. To rule out the latter, force the commit right after
+ * the ALTER, as PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index dbbe5904256..478fb5c0071 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1316,6 +1316,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index f9daf5b744c..3123d4aa8dd 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..76899112108 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,126 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..9cfd4632ec1 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,82 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From 681ba2f13cdbda815b2ad425ca0e919b9085ad98 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:50:40 -0300
Subject: [PATCH v5] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a top-level statement outside a transaction block, so
nothing can modify the table before the transaction ends. This
requires that no ddl_command_end event trigger runs after it, and the
commit is forced right after the statement, as for
PreventInTransactionBlock, so that a later statement of an
extended-protocol pipeline cannot share the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that in a transaction block a rollback after inserting
or truncating leaves the index file at its pre-transaction size; that an
index created or moved earlier in the subtransaction is not copied; and
that after a rollback to a savepoint, an index created, reindexed or
already copied before the savepoint returns no row for the rolled-back
values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 132 +++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/input/tablespace.source | 76 +++++++++++++
src/test/regress/output/tablespace.source | 120 ++++++++++++++++++++
5 files changed, 325 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..12ae8916d26 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -89,6 +89,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -549,7 +550,10 @@ static void ATExecDropCluster(Relation rel, LOCKMODE lockmode);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5582,7 +5586,8 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(context));
}
}
@@ -14198,13 +14203,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14222,6 +14230,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14270,6 +14279,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14277,12 +14294,117 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Nothing can when the ALTER is a top-level statement outside any
+ * transaction block and nothing runs after it before the commit: no
+ * ddl_command_end event trigger, and no further statement of an
+ * extended-protocol pipeline, which would otherwise share the transaction
+ * until the next Sync. To rule out the latter, force the commit right after
+ * the ALTER, as PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 51509efbfc1..cbfd800ad35 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1308,6 +1308,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 212e9b32806..7cd9a891d2b 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..783c8f9ecf3 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,82 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..a453035ec45 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,126 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
Attachments:
[text/plain] us-ascii''v5-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (20.2K, ../../179096965838.633820.14465709390909927649@gmail.com/2-us-ascii%27%27v5-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From f818dfd16cd2121b8b85f919d03ea494dd43f6e0 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Thu, 1 Oct 2026 21:37:04 -0300
Subject: [PATCH v5] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a top-level statement outside a transaction block, so
nothing can modify the table before the transaction ends. This
requires that no ddl_command_end event trigger runs after it, and the
commit is forced right after the statement, as for
PreventInTransactionBlock, so that a later statement of an
extended-protocol pipeline cannot share the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that in a transaction block a rollback after inserting
or truncating leaves the index file at its pre-transaction size; that an
index created or moved earlier in the subtransaction is not copied; and
that after a rollback to a savepoint, an index created, reindexed or
already copied before the savepoint returns no row for the rolled-back
values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 132 ++++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 120 +++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 76 +++++++++++++
5 files changed, 325 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..dcfbb362c73 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -98,6 +98,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -700,7 +701,10 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -6097,7 +6101,8 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(context));
}
/*
@@ -17526,13 +17531,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -17550,6 +17558,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -17596,6 +17605,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -17603,12 +17620,117 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Nothing can when the ALTER is a top-level statement outside any
+ * transaction block and nothing runs after it before the commit: no
+ * ddl_command_end event trigger, and no further statement of an
+ * extended-protocol pipeline, which would otherwise share the transaction
+ * until the next Sync. To rule out the latter, force the commit right after
+ * the ALTER, as PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 4d33fcb5e9d..57d88f371ef 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1315,6 +1315,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index abbdb401bf6..0357550da71 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..ccc43061aad 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,126 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..8fc94894491 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,82 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v5-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (20.1K, ../../179096965838.633820.14465709390909927649@gmail.com/3-us-ascii%27%27v5-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 12d170ec0f1ad3eed71030b205b9bd4d42c72644 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:47:53 -0300
Subject: [PATCH v5] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a top-level statement outside a transaction block, so
nothing can modify the table before the transaction ends. This
requires that no ddl_command_end event trigger runs after it, and the
commit is forced right after the statement, as for
PreventInTransactionBlock, so that a later statement of an
extended-protocol pipeline cannot share the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that in a transaction block a rollback after inserting
or truncating leaves the index file at its pre-transaction size; that an
index created or moved earlier in the subtransaction is not copied; and
that after a rollback to a savepoint, an index created, reindexed or
already copied before the savepoint returns no row for the rolled-back
values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 132 ++++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 120 +++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 76 +++++++++++++
5 files changed, 325 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..ea4245406c3 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -593,7 +594,10 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5670,7 +5674,8 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(context));
}
/*
@@ -14791,13 +14796,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14815,6 +14823,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14861,6 +14870,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14868,12 +14885,117 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Nothing can when the ALTER is a top-level statement outside any
+ * transaction block and nothing runs after it before the commit: no
+ * ddl_command_end event trigger, and no further statement of an
+ * extended-protocol pipeline, which would otherwise share the transaction
+ * until the next Sync. To rule out the latter, force the commit right after
+ * the ALTER, as PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index dbbe5904256..478fb5c0071 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1316,6 +1316,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index f9daf5b744c..3123d4aa8dd 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..76899112108 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,126 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..9cfd4632ec1 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,82 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v5-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (20.1K, ../../179096965838.633820.14465709390909927649@gmail.com/4-us-ascii%27%27v5-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 681ba2f13cdbda815b2ad425ca0e919b9085ad98 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:50:40 -0300
Subject: [PATCH v5] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a top-level statement outside a transaction block, so
nothing can modify the table before the transaction ends. This
requires that no ddl_command_end event trigger runs after it, and the
commit is forced right after the statement, as for
PreventInTransactionBlock, so that a later statement of an
extended-protocol pipeline cannot share the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that in a transaction block a rollback after inserting
or truncating leaves the index file at its pre-transaction size; that an
index created or moved earlier in the subtransaction is not copied; and
that after a rollback to a savepoint, an index created, reindexed or
already copied before the savepoint returns no row for the rolled-back
values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 132 +++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/input/tablespace.source | 76 +++++++++++++
src/test/regress/output/tablespace.source | 120 ++++++++++++++++++++
5 files changed, 325 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..12ae8916d26 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -89,6 +89,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -549,7 +550,10 @@ static void ATExecDropCluster(Relation rel, LOCKMODE lockmode);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5582,7 +5586,8 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(context));
}
}
@@ -14198,13 +14203,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14222,6 +14230,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14270,6 +14279,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14277,12 +14294,117 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Nothing can when the ALTER is a top-level statement outside any
+ * transaction block and nothing runs after it before the commit: no
+ * ddl_command_end event trigger, and no further statement of an
+ * extended-protocol pipeline, which would otherwise share the transaction
+ * until the next Sync. To rule out the latter, force the commit right after
+ * the ALTER, as PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 51509efbfc1..cbfd800ad35 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1308,6 +1308,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 212e9b32806..7cd9a891d2b 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..783c8f9ecf3 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,82 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..a453035ec45 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,126 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-03 04:26 shihao zhong <zhong950419@gmail.com>
parent: Manu <manuelreyesbravo@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: shihao zhong @ 2026-10-03 04:26 UTC (permalink / raw)
To: Manu <manuelreyesbravo@gmail.com>; +Cc: Andres Freund <andres@anarazel.de>; Tom Lane <tgl@sss.pgh.pa.us>; Alexandre Felipe <o.alexandre.felipe@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi Manu,
v5 looks good. One small thing.
A sql_drop event trigger also runs after the ALTER, and v5 only
checks ddl_command_end. Andres said a superuser can keep the pieces
here, so maybe neither needs a check. But if you check one, the
other should be checked too.
CREATE TABLE t (a int, b int);
CREATE INDEX ON t (a);
CREATE FUNCTION f() RETURNS event_trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO t VALUES (5);
RAISE EXCEPTION 'fail';
END $$;
CREATE EVENT TRIGGER e ON sql_drop EXECUTE FUNCTION f();
ALTER TABLE t SET TABLESPACE ts, DROP COLUMN b;
DROP EVENT TRIGGER e;
INSERT INTO t VALUES (7);
SET enable_seqscan = off;
SELECT count(*) FROM t WHERE a = 5;
This returns 1 with v5, and 0 with the attached diff on top of v5.
Also, ALTER TABLE ALL IN TABLESPACE always copies the indexes. Is that on
purpose?
Thanks,
Shihao
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index bd213461ef1..96592f1956e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -17643,7 +17643,8 @@ ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
{
if (context == NULL || IsInTransactionBlock(context->isTopLevel))
return true;
- if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL ||
+ EventCacheLookup(EVT_SQLDrop) != NIL)
return true;
MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
Attachments:
[text/plain] nocfbot-v5-sql-drop-check.diff.txt (580B, ../../CAGRkXqRNC2X1RR3Bt1gA3NwG5k0WQQSRxS71hVnCekFwPx_cYw@mail.gmail.com/3-nocfbot-v5-sql-drop-check.diff.txt)
download | inline diff:
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index bd213461ef1..96592f1956e 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -17643,7 +17643,8 @@ ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
{
if (context == NULL || IsInTransactionBlock(context->isTopLevel))
return true;
- if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL ||
+ EventCacheLookup(EVT_SQLDrop) != NIL)
return true;
MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-03 14:39 Manu <manuelreyesbravo@gmail.com>
parent: shihao zhong <zhong950419@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Manu @ 2026-10-03 14:39 UTC (permalink / raw)
To: shihao zhong <zhong950419@gmail.com>; +Cc: Andres Freund <andres@anarazel.de>; Tom Lane <tgl@sss.pgh.pa.us>; Alexandre Felipe <o.alexandre.felipe@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi Shihao,
Thanks for catching this.
> A sql_drop event trigger also runs after the ALTER, and v5 only
> checks ddl_command_end.
Right, and it is not the only one. An ALTER that also adds a CHECK
constraint validates it on the table's inheritance children after the
table itself was moved, so a volatile check function can write to the
table and then fail. That needs no event trigger, so no superuser: an
ordinary role that owns the table leaves 50 stale index entries with
v5, and your diff does not cover it.
Rather than list every such place, v6 keeps the shortcut only for a
plain ALTER TABLE ... SET TABLESPACE of a single table, with no other
subcommand. Then nothing else is in the work queue, nothing is dropped
and nothing is validated, so a ddl_command_end trigger and a pipeline
are all that remain, as in v5. Any combined ALTER copies the indexes.
Stale entries after the abort, for v5, v5 with your diff, and v6:
sql_drop trigger: 50, 0, 0
CHECK on an inheritance child: 50, 50, 0
ddl_command_end trigger, pipeline: 0, 0, 0
A plain SET TABLESPACE still leaves the index files alone. The
regression test gains the child CHECK case. It passes on master,
REL_15 and REL_14 (243, 217 and 216 tests), and the standby, crash
and wal_level=minimal checks stay clean.
> Also, ALTER TABLE ALL IN TABLESPACE always copies the indexes. Is
> that on purpose?
Yes. It moves each table through AlterTableInternal(), which has no
utility context, and moves them all in one transaction, which can
also be a transaction block, so the shortcut's condition cannot hold
there.
Attached are v6 for master, REL_15 and REL_14, and the change from v5.
Regards,
Manu
From 601ecf002d25427a7081d506df162c105a10fb92 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Thu, 1 Oct 2026 21:37:04 -0300
Subject: [PATCH v6] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, issued as a top-level statement
outside a transaction block, so nothing can modify the table before
the transaction ends. Any other subcommand could run user code after
the move: the validation of an inheritance child's new CHECK
constraint, a sql_drop event trigger, and so on. This also requires
that no ddl_command_end event trigger runs after it, and the commit is
forced right after the statement, as for PreventInTransactionBlock, so
that a later statement of an extended-protocol pipeline cannot share
the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails,
it leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 145 ++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 153 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 102 +++++++++++++++
5 files changed, 397 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..a59581cad9a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -98,6 +98,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -700,7 +701,11 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -6097,7 +6102,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -17526,13 +17533,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -17550,6 +17560,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -17596,6 +17607,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -17603,12 +17622,128 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, issued as a top-level statement outside any transaction block.
+ * What can still run after it is a ddl_command_end event trigger, and a
+ * further statement of an extended-protocol pipeline, which would share the
+ * transaction until the next Sync. Rule out the former by looking for such
+ * triggers, and the latter by forcing the commit right after the ALTER, as
+ * PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 4d33fcb5e9d..57d88f371ef 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1315,6 +1315,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index abbdb401bf6..0357550da71 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..aec469aee9f 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..52cf5d5a09e 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From 33a6523c8a5e694281b09228584bad846ff01766 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:47:53 -0300
Subject: [PATCH v6] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, issued as a top-level statement
outside a transaction block, so nothing can modify the table before
the transaction ends. Any other subcommand could run user code after
the move: the validation of an inheritance child's new CHECK
constraint, a sql_drop event trigger, and so on. This also requires
that no ddl_command_end event trigger runs after it, and the commit is
forced right after the statement, as for PreventInTransactionBlock, so
that a later statement of an extended-protocol pipeline cannot share
the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails,
it leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 145 ++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 153 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 102 +++++++++++++++
5 files changed, 397 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..3e2f8e54dda 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -593,7 +594,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5670,7 +5675,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14791,13 +14798,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14815,6 +14825,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14861,6 +14872,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14868,12 +14887,128 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, issued as a top-level statement outside any transaction block.
+ * What can still run after it is a ddl_command_end event trigger, and a
+ * further statement of an extended-protocol pipeline, which would share the
+ * transaction until the next Sync. Rule out the former by looking for such
+ * triggers, and the latter by forcing the commit right after the ALTER, as
+ * PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index dbbe5904256..478fb5c0071 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1316,6 +1316,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index f9daf5b744c..3123d4aa8dd 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..1fd24491262 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..39669a5a14b 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From 6ae6c3233f5c259e864e024104f42fd7c5229a4b Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:50:40 -0300
Subject: [PATCH v6] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, issued as a top-level statement
outside a transaction block, so nothing can modify the table before
the transaction ends. Any other subcommand could run user code after
the move: the validation of an inheritance child's new CHECK
constraint, a sql_drop event trigger, and so on. This also requires
that no ddl_command_end event trigger runs after it, and the commit is
forced right after the statement, as for PreventInTransactionBlock, so
that a later statement of an extended-protocol pipeline cannot share
the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails,
it leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 145 +++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/input/tablespace.source | 102 +++++++++++++++
src/test/regress/output/tablespace.source | 153 ++++++++++++++++++++++
5 files changed, 397 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..dad0fb0b77a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -89,6 +89,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -549,7 +550,11 @@ static void ATExecDropCluster(Relation rel, LOCKMODE lockmode);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5582,7 +5587,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
}
@@ -14198,13 +14205,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14222,6 +14232,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14270,6 +14281,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14277,12 +14296,128 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, issued as a top-level statement outside any transaction block.
+ * What can still run after it is a ddl_command_end event trigger, and a
+ * further statement of an extended-protocol pipeline, which would share the
+ * transaction until the next Sync. Rule out the former by looking for such
+ * triggers, and the latter by forcing the commit right after the ALTER, as
+ * PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 51509efbfc1..cbfd800ad35 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1308,6 +1308,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 212e9b32806..7cd9a891d2b 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..a6d699f4b65 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..5369b98c8de 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index dcfbb362c73..a59581cad9a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -703,7 +703,8 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
bool copyIndexes);
-static bool ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
@@ -6102,7 +6103,8 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
*/
if (tab->newTableSpace)
ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
- ATSetTableSpaceCopyIndexes(context));
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -17641,18 +17643,29 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
* Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
*
* Only if something can still modify the table in the same transaction after
- * the move. Nothing can when the ALTER is a top-level statement outside any
- * transaction block and nothing runs after it before the commit: no
- * ddl_command_end event trigger, and no further statement of an
- * extended-protocol pipeline, which would otherwise share the transaction
- * until the next Sync. To rule out the latter, force the commit right after
- * the ALTER, as PreventInTransactionBlock does.
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, issued as a top-level statement outside any transaction block.
+ * What can still run after it is a ddl_command_end event trigger, and a
+ * further statement of an extended-protocol pipeline, which would share the
+ * transaction until the next Sync. Rule out the former by looking for such
+ * triggers, and the latter by forcing the commit right after the ALTER, as
+ * PreventInTransactionBlock does.
*/
static bool
-ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
{
if (context == NULL || IsInTransactionBlock(context->isTopLevel))
return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
return true;
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index ccc43061aad..aec469aee9f 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -1071,6 +1071,39 @@ SELECT count(*) FROM tbspace_subxact;
RESET enable_seqscan;
RESET enable_bitmapscan;
DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 8fc94894491..52cf5d5a09e 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -495,6 +495,32 @@ SELECT count(*) FROM tbspace_subxact;
RESET enable_seqscan;
RESET enable_bitmapscan;
DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
Attachments:
[text/plain] us-ascii''v6-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (23.9K, ../../179103835723.49042.1921055480892788753@gmail.com/2-us-ascii%27%27v6-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 601ecf002d25427a7081d506df162c105a10fb92 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Thu, 1 Oct 2026 21:37:04 -0300
Subject: [PATCH v6] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, issued as a top-level statement
outside a transaction block, so nothing can modify the table before
the transaction ends. Any other subcommand could run user code after
the move: the validation of an inheritance child's new CHECK
constraint, a sql_drop event trigger, and so on. This also requires
that no ddl_command_end event trigger runs after it, and the commit is
forced right after the statement, as for PreventInTransactionBlock, so
that a later statement of an extended-protocol pipeline cannot share
the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails,
it leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 145 ++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 153 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 102 +++++++++++++++
5 files changed, 397 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..a59581cad9a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -98,6 +98,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -700,7 +701,11 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -6097,7 +6102,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -17526,13 +17533,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -17550,6 +17560,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -17596,6 +17607,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -17603,12 +17622,128 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, issued as a top-level statement outside any transaction block.
+ * What can still run after it is a ddl_command_end event trigger, and a
+ * further statement of an extended-protocol pipeline, which would share the
+ * transaction until the next Sync. Rule out the former by looking for such
+ * triggers, and the latter by forcing the commit right after the ALTER, as
+ * PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 4d33fcb5e9d..57d88f371ef 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1315,6 +1315,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index abbdb401bf6..0357550da71 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..aec469aee9f 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..52cf5d5a09e 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v6-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (23.9K, ../../179103835723.49042.1921055480892788753@gmail.com/3-us-ascii%27%27v6-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 33a6523c8a5e694281b09228584bad846ff01766 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:47:53 -0300
Subject: [PATCH v6] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, issued as a top-level statement
outside a transaction block, so nothing can modify the table before
the transaction ends. Any other subcommand could run user code after
the move: the validation of an inheritance child's new CHECK
constraint, a sql_drop event trigger, and so on. This also requires
that no ddl_command_end event trigger runs after it, and the commit is
forced right after the statement, as for PreventInTransactionBlock, so
that a later statement of an extended-protocol pipeline cannot share
the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails,
it leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 145 ++++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 153 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 102 +++++++++++++++
5 files changed, 397 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..3e2f8e54dda 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -593,7 +594,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5670,7 +5675,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14791,13 +14798,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14815,6 +14825,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14861,6 +14872,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14868,12 +14887,128 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, issued as a top-level statement outside any transaction block.
+ * What can still run after it is a ddl_command_end event trigger, and a
+ * further statement of an extended-protocol pipeline, which would share the
+ * transaction until the next Sync. Rule out the former by looking for such
+ * triggers, and the latter by forcing the commit right after the ALTER, as
+ * PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index dbbe5904256..478fb5c0071 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1316,6 +1316,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index f9daf5b744c..3123d4aa8dd 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..1fd24491262 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..39669a5a14b 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v6-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (23.9K, ../../179103835723.49042.1921055480892788753@gmail.com/4-us-ascii%27%27v6-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 6ae6c3233f5c259e864e024104f42fd7c5229a4b Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:50:40 -0300
Subject: [PATCH v6] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, issued as a top-level statement
outside a transaction block, so nothing can modify the table before
the transaction ends. Any other subcommand could run user code after
the move: the validation of an inheritance child's new CHECK
constraint, a sql_drop event trigger, and so on. This also requires
that no ddl_command_end event trigger runs after it, and the commit is
forced right after the statement, as for PreventInTransactionBlock, so
that a later statement of an extended-protocol pipeline cannot share
the transaction.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails,
it leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 145 +++++++++++++++++++-
src/backend/tcop/utility.c | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/input/tablespace.source | 102 +++++++++++++++
src/test/regress/output/tablespace.source | 153 ++++++++++++++++++++++
5 files changed, 397 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..dad0fb0b77a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -89,6 +89,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -549,7 +550,11 @@ static void ATExecDropCluster(Relation rel, LOCKMODE lockmode);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5582,7 +5587,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
}
@@ -14198,13 +14205,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14222,6 +14232,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14270,6 +14281,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14277,12 +14296,128 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, issued as a top-level statement outside any transaction block.
+ * What can still run after it is a ddl_command_end event trigger, and a
+ * further statement of an extended-protocol pipeline, which would share the
+ * transaction until the next Sync. Rule out the former by looking for such
+ * triggers, and the latter by forcing the commit right after the ALTER, as
+ * PreventInTransactionBlock does.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero after
+ * a rollback to a savepoint even though the file is new; then this falls
+ * back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 51509efbfc1..cbfd800ad35 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1308,6 +1308,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 212e9b32806..7cd9a891d2b 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..a6d699f4b65 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..5369b98c8de 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] nocfbot-v5-to-v6.diff.txt (6.8K, ../../179103835723.49042.1921055480892788753@gmail.com/5-nocfbot-v5-to-v6.diff.txt)
download | inline diff:
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index dcfbb362c73..a59581cad9a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -703,7 +703,8 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
bool copyIndexes);
-static bool ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
@@ -6102,7 +6103,8 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
*/
if (tab->newTableSpace)
ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
- ATSetTableSpaceCopyIndexes(context));
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -17641,18 +17643,29 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
* Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
*
* Only if something can still modify the table in the same transaction after
- * the move. Nothing can when the ALTER is a top-level statement outside any
- * transaction block and nothing runs after it before the commit: no
- * ddl_command_end event trigger, and no further statement of an
- * extended-protocol pipeline, which would otherwise share the transaction
- * until the next Sync. To rule out the latter, force the commit right after
- * the ALTER, as PreventInTransactionBlock does.
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, issued as a top-level statement outside any transaction block.
+ * What can still run after it is a ddl_command_end event trigger, and a
+ * further statement of an extended-protocol pipeline, which would share the
+ * transaction until the next Sync. Rule out the former by looking for such
+ * triggers, and the latter by forcing the commit right after the ALTER, as
+ * PreventInTransactionBlock does.
*/
static bool
-ATSetTableSpaceCopyIndexes(AlterTableUtilityContext *context)
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
{
if (context == NULL || IsInTransactionBlock(context->isTopLevel))
return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
return true;
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index ccc43061aad..aec469aee9f 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -1071,6 +1071,39 @@ SELECT count(*) FROM tbspace_subxact;
RESET enable_seqscan;
RESET enable_bitmapscan;
DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 8fc94894491..52cf5d5a09e 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -495,6 +495,32 @@ SELECT count(*) FROM tbspace_subxact;
RESET enable_seqscan;
RESET enable_bitmapscan;
DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-04 04:22 shihao zhong <zhong950419@gmail.com>
parent: Manu <manuelreyesbravo@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: shihao zhong @ 2026-10-04 04:22 UTC (permalink / raw)
To: Manu <manuelreyesbravo@gmail.com>; +Cc: Andres Freund <andres@anarazel.de>; Tom Lane <tgl@sss.pgh.pa.us>; Alexandre Felipe <o.alexandre.felipe@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi Manu,
v6 looks good to me. The child CHECK case, VALIDATE CONSTRAINT and
sql_drop all leave 0 stale entries now. One small thing.
ATSetTableSpaceCopyIndexes() also runs for ALTER INDEX, so a plain
ALTER INDEX SET TABLESPACE in a pipeline now commits on its own.
Maybe only call it for tables and matviews.
I recently got interested in replication lag with physical
replication, so I also looked at the replay side on a standby.
A plain top-level ALTER costs the same as master. In a transaction
block it costs more.
WAL size. The index copy is logged page by page, so the extra WAL
is the size of the indexes. Given they are mostly FPI, I think it is okay
for
replicas to quickly catch up.
AccessExclusiveLock. Each index now gets one too, so one more lock
record per index goes to the standby. I think that is okay. The
table already has that lock, and queries lock the table before its
indexes, so nobody new is blocked.
Thanks,
Shihao
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-04 08:25 Manu <manuelreyesbravo@gmail.com>
parent: shihao zhong <zhong950419@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: Manu @ 2026-10-04 08:25 UTC (permalink / raw)
To: shihao zhong <zhong950419@gmail.com>; +Cc: Andres Freund <andres@anarazel.de>; Tom Lane <tgl@sss.pgh.pa.us>; Alexandre Felipe <o.alexandre.felipe@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi Shihao,
> ATSetTableSpaceCopyIndexes() also runs for ALTER INDEX, so a plain
> ALTER INDEX SET TABLESPACE in a pipeline now commits on its own.
> Maybe only call it for tables and matviews.
Thanks. It is not only ALTER INDEX: as the first statement of a
pipeline, ALTER TABLE and ALTER MATERIALIZED VIEW also commit on their
own with v6, so a later failure no longer rolls the move back. The
forced commit itself is the problem.
v7 drops it. The shortcut is taken only when the ALTER is the only
statement of a simple Query message, outside a transaction block.
Through the extended protocol it always copies the indexes, since the
backend cannot tell whether more statements will come before Sync.
postgres.c gets a small accessor for that, IsExtendedQueryMessage().
In a pipeline whose next statement fails, the table, index and
matview now stay where they were, as on master.
> A plain top-level ALTER costs the same as master. In a transaction
> block it costs more.
With v7 that holds for the simple protocol only; through the extended
protocol, as most drivers send it, the ALTER copies the indexes. For
1M rows with 29 MB of indexes, master and v7:
WAL, plain ALTER: 35.6 MB, 35.6 MB
WAL, in a block or extended: 35.6 MB, 64.9 MB
AccessExclusiveLocks, in a block: 1, 3
The copy also needs free space where the indexes are. With them on a
nearly full tablespace, moving the table in a block fails on v7 with
"could not extend file"; running the ALTER on its own still works.
In the back branches the patch adds a field at the end of
AlterTableUtilityContext, which TimescaleDB and Apache AGE build to
call AlterTable(). The field is read only for SET TABLESPACE. Built
against the unpatched headers, both run unchanged on the patched
branches, and a garbage value can at worst skip the copy, as today.
If that is unwelcome, the back branches could always copy instead.
Attached are v7 for master (it applies to REL_19 and REL_18 as is),
REL_17, REL_16, REL_15 and REL_14, and the change from v6. Earlier
versions missed REL_17 and REL_16, where the master patch does not
apply. On every branch v7 passes check-world, the random savepoint
test and the standby and crash checks.
Regards,
Manu
From 33194eaa29ccf9da6a38f373c2c810af5c48d3be Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Thu, 1 Oct 2026 21:37:04 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0274d892f2e..c4bf9fab96a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -98,6 +98,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -694,7 +695,11 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -6087,7 +6092,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -17516,13 +17523,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -17540,6 +17550,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -17586,6 +17597,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -17593,12 +17612,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b6bdfe213fe..1663cf7aea3 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2457,6 +2457,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 1512d2df196..35f248895d4 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1312,6 +1312,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 5bc5bcfb20d..99e83189946 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -86,6 +86,7 @@ pg_noreturn extern void PostgresMain(const char *dbname,
const char *username);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index abbdb401bf6..0357550da71 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..349a2d3a9b1 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..e210409fb00 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From 88785c2c22cfd7c108e642f9d174a4cac0a6b11d Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sun, 4 Oct 2026 04:12:30 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index d4bc38b66e8..22ef44218f7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -94,6 +94,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -611,7 +612,11 @@ static void ATExecSetAccessMethodNoStorage(Relation rel, Oid newAccessMethodId);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5900,7 +5905,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -15288,13 +15295,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -15312,6 +15322,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -15358,6 +15369,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -15365,12 +15384,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 696fd18575c..085dde9cc41 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2387,6 +2387,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 29ef8beaa81..f0401102a1a 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1309,6 +1309,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 180d5a2d227..416d410b476 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -88,6 +88,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 9aa1de38790..378e28d1b76 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 9c561395970..47c24b47a38 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -949,6 +949,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 0f9c136d317..1008038d556 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From 4488f14890045641d1c3e13e855f671e90d34c4a Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sun, 4 Oct 2026 04:12:30 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f955813cbf0..503fd6a94a1 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -594,7 +595,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5732,7 +5737,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14818,13 +14825,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14842,6 +14852,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14888,6 +14899,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14895,12 +14914,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 7ba3e283dc3..f29df61c486 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2390,6 +2390,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 570802ff26b..3a63bb09ca6 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1320,6 +1320,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index e529e9f06c2..d4e99350eeb 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -90,6 +90,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 59e64aea073..e2c594a9ee3 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 9c561395970..47c24b47a38 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -949,6 +949,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 0f9c136d317..1008038d556 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From 1d94bd53afbd68e32851d43ff9c6cc47ab71b551 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:47:53 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails, it
leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its pre-
transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 150 +++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 153 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 102 +++++++++++++++
7 files changed, 420 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..8ffb77da954 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -593,7 +594,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5670,7 +5675,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14791,13 +14798,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14815,6 +14825,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14861,6 +14872,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14868,12 +14887,133 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero
+ * after a rollback to a savepoint even though the file is new; then this
+ * falls back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 2ae682e2221..2300f5c71f7 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2324,6 +2324,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index dbbe5904256..478fb5c0071 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1316,6 +1316,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index d7d307bbfab..f21da20aaa8 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -96,6 +96,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index f9daf5b744c..3123d4aa8dd 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..1fd24491262 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..39669a5a14b 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
From 89e4bd766393342b095246fec388fa9e7f31b1fc Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:50:40 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails, it
leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its pre-
transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 150 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/input/tablespace.source | 102 +++++++++++++++
src/test/regress/output/tablespace.source | 153 ++++++++++++++++++++++
7 files changed, 420 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..77e7bc4d105 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -89,6 +89,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -549,7 +550,11 @@ static void ATExecDropCluster(Relation rel, LOCKMODE lockmode);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5582,7 +5587,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
}
@@ -14198,13 +14205,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14222,6 +14232,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14270,6 +14281,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14277,12 +14296,133 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero
+ * after a rollback to a savepoint even though the file is new; then this
+ * falls back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 0e64b04bd16..34b82cbb24b 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2309,6 +2309,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 51509efbfc1..cbfd800ad35 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1308,6 +1308,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 00da5e66e70..e85e47b241c 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -90,6 +90,7 @@ extern void PostgresMain(int argc, char *argv[],
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 212e9b32806..7cd9a891d2b 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..a6d699f4b65 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..5369b98c8de 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 88031c0c5fe..c4bf9fab96a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -17621,8 +17621,9 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
/*
* The heap now has a new relfilenode. Give each of the table's indexes a
- * fresh relfilenode too, so that the indexes share the heap's rewrite fate
- * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenumber.
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
*/
foreach(lc, reltabidxids)
ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
@@ -17639,12 +17640,16 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
* sql_drop and table_rewrite event triggers, and more -- the files are kept
* only for the one case where none of them exists: a plain
* "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
- * subcommand, issued as a top-level statement outside any transaction block.
- * What can still run after it is a ddl_command_end event trigger, and a
- * further statement of an extended-protocol pipeline, which would share the
- * transaction until the next Sync. Rule out the former by looking for such
- * triggers, and the latter by forcing the commit right after the ALTER, as
- * PreventInTransactionBlock does.
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
*/
static bool
ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
@@ -17652,6 +17657,8 @@ ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
{
if (context == NULL || IsInTransactionBlock(context->isTopLevel))
return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
list_length(wqueue) != 1)
@@ -17659,7 +17666,6 @@ ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
return true;
- MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
return false;
}
@@ -17692,9 +17698,10 @@ ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
* Only plain indexes have storage that can hold the stale entries. An
* index whose current file was created in this subtransaction (the index
* itself, or a new file for it) is discarded together with the heap's new
- * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be zero after
- * a rollback to a savepoint even though the file is new; then this falls
- * back to rd_createSubid, and copies if that does not match either.
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
*/
if (ind->rd_rel->relkind != RELKIND_INDEX ||
!RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b6bdfe213fe..1663cf7aea3 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2457,6 +2457,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 5bc5bcfb20d..99e83189946 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -86,6 +86,7 @@ pg_noreturn extern void PostgresMain(const char *dbname,
const char *username);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index aec469aee9f..349a2d3a9b1 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -965,6 +965,17 @@ SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_ke
t
(1 row)
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
-- In a transaction block the indexes get new files, so that a rollback
-- discards the entries added after the move together with the heap's file.
BEGIN;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 52cf5d5a09e..e210409fb00 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -429,6 +429,12 @@ SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
-- In a transaction block the indexes get new files, so that a rollback
-- discards the entries added after the move together with the heap's file.
BEGIN;
Attachments:
[text/plain] us-ascii''v7-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (26.8K, ../../179110230672.1690838.3721224931432361508@gmail.com/2-us-ascii%27%27v7-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 33194eaa29ccf9da6a38f373c2c810af5c48d3be Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Thu, 1 Oct 2026 21:37:04 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0274d892f2e..c4bf9fab96a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -98,6 +98,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -694,7 +695,11 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -6087,7 +6092,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -17516,13 +17523,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -17540,6 +17550,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -17586,6 +17597,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -17593,12 +17612,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b6bdfe213fe..1663cf7aea3 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2457,6 +2457,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 1512d2df196..35f248895d4 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1312,6 +1312,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 5bc5bcfb20d..99e83189946 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -86,6 +86,7 @@ pg_noreturn extern void PostgresMain(const char *dbname,
const char *username);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index abbdb401bf6..0357550da71 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..349a2d3a9b1 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..e210409fb00 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v7-REL_17-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (26.8K, ../../179110230672.1690838.3721224931432361508@gmail.com/3-us-ascii%27%27v7-REL_17-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 88785c2c22cfd7c108e642f9d174a4cac0a6b11d Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sun, 4 Oct 2026 04:12:30 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index d4bc38b66e8..22ef44218f7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -94,6 +94,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -611,7 +612,11 @@ static void ATExecSetAccessMethodNoStorage(Relation rel, Oid newAccessMethodId);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5900,7 +5905,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -15288,13 +15295,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -15312,6 +15322,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -15358,6 +15369,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -15365,12 +15384,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 696fd18575c..085dde9cc41 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2387,6 +2387,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 29ef8beaa81..f0401102a1a 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1309,6 +1309,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 180d5a2d227..416d410b476 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -88,6 +88,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 9aa1de38790..378e28d1b76 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 9c561395970..47c24b47a38 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -949,6 +949,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 0f9c136d317..1008038d556 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v7-REL_16-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (26.8K, ../../179110230672.1690838.3721224931432361508@gmail.com/4-us-ascii%27%27v7-REL_16-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 4488f14890045641d1c3e13e855f671e90d34c4a Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sun, 4 Oct 2026 04:12:30 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f955813cbf0..503fd6a94a1 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -594,7 +595,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5732,7 +5737,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14818,13 +14825,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14842,6 +14852,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14888,6 +14899,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14895,12 +14914,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 7ba3e283dc3..f29df61c486 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2390,6 +2390,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 570802ff26b..3a63bb09ca6 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1320,6 +1320,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index e529e9f06c2..d4e99350eeb 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -90,6 +90,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 59e64aea073..e2c594a9ee3 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 9c561395970..47c24b47a38 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -949,6 +949,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 0f9c136d317..1008038d556 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v7-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (25.8K, ../../179110230672.1690838.3721224931432361508@gmail.com/5-us-ascii%27%27v7-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 1d94bd53afbd68e32851d43ff9c6cc47ab71b551 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:47:53 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails, it
leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its pre-
transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 150 +++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 153 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 102 +++++++++++++++
7 files changed, 420 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..8ffb77da954 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -593,7 +594,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5670,7 +5675,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14791,13 +14798,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14815,6 +14825,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14861,6 +14872,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14868,12 +14887,133 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero
+ * after a rollback to a savepoint even though the file is new; then this
+ * falls back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 2ae682e2221..2300f5c71f7 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2324,6 +2324,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index dbbe5904256..478fb5c0071 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1316,6 +1316,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index d7d307bbfab..f21da20aaa8 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -96,6 +96,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index f9daf5b744c..3123d4aa8dd 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..1fd24491262 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..39669a5a14b 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] us-ascii''v7-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (25.8K, ../../179110230672.1690838.3721224931432361508@gmail.com/6-us-ascii%27%27v7-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 89e4bd766393342b095246fec388fa9e7f31b1fc Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:50:40 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails, it
leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its pre-
transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 150 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/input/tablespace.source | 102 +++++++++++++++
src/test/regress/output/tablespace.source | 153 ++++++++++++++++++++++
7 files changed, 420 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..77e7bc4d105 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -89,6 +89,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -549,7 +550,11 @@ static void ATExecDropCluster(Relation rel, LOCKMODE lockmode);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5582,7 +5587,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
}
@@ -14198,13 +14205,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14222,6 +14232,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14270,6 +14281,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14277,12 +14296,133 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero
+ * after a rollback to a savepoint even though the file is new; then this
+ * falls back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 0e64b04bd16..34b82cbb24b 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2309,6 +2309,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 51509efbfc1..cbfd800ad35 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1308,6 +1308,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 00da5e66e70..e85e47b241c 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -90,6 +90,7 @@ extern void PostgresMain(int argc, char *argv[],
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 212e9b32806..7cd9a891d2b 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..a6d699f4b65 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..5369b98c8de 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
--
2.55.0
[text/plain] nocfbot-v6-to-v7.diff.txt (7.2K, ../../179110230672.1690838.3721224931432361508@gmail.com/7-nocfbot-v6-to-v7.diff.txt)
download | inline diff:
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 88031c0c5fe..c4bf9fab96a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -17621,8 +17621,9 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
/*
* The heap now has a new relfilenode. Give each of the table's indexes a
- * fresh relfilenode too, so that the indexes share the heap's rewrite fate
- * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenumber.
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
*/
foreach(lc, reltabidxids)
ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
@@ -17639,12 +17640,16 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
* sql_drop and table_rewrite event triggers, and more -- the files are kept
* only for the one case where none of them exists: a plain
* "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
- * subcommand, issued as a top-level statement outside any transaction block.
- * What can still run after it is a ddl_command_end event trigger, and a
- * further statement of an extended-protocol pipeline, which would share the
- * transaction until the next Sync. Rule out the former by looking for such
- * triggers, and the latter by forcing the commit right after the ALTER, as
- * PreventInTransactionBlock does.
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
*/
static bool
ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
@@ -17652,6 +17657,8 @@ ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
{
if (context == NULL || IsInTransactionBlock(context->isTopLevel))
return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
list_length(wqueue) != 1)
@@ -17659,7 +17666,6 @@ ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
return true;
- MyXactFlags |= XACT_FLAGS_NEEDIMMEDIATECOMMIT;
return false;
}
@@ -17692,9 +17698,10 @@ ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
* Only plain indexes have storage that can hold the stale entries. An
* index whose current file was created in this subtransaction (the index
* itself, or a new file for it) is discarded together with the heap's new
- * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be zero after
- * a rollback to a savepoint even though the file is new; then this falls
- * back to rd_createSubid, and copies if that does not match either.
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
*/
if (ind->rd_rel->relkind != RELKIND_INDEX ||
!RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b6bdfe213fe..1663cf7aea3 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2457,6 +2457,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 5bc5bcfb20d..99e83189946 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -86,6 +86,7 @@ pg_noreturn extern void PostgresMain(const char *dbname,
const char *username);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index aec469aee9f..349a2d3a9b1 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -965,6 +965,17 @@ SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_ke
t
(1 row)
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
-- In a transaction block the indexes get new files, so that a rollback
-- discards the entries added after the move together with the heap's file.
BEGIN;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 52cf5d5a09e..e210409fb00 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -429,6 +429,12 @@ SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
-- In a transaction block the indexes get new files, so that a rollback
-- discards the entries added after the move together with the heap's file.
BEGIN;
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-04 19:14 shihao zhong <zhong950419@gmail.com>
parent: Manu <manuelreyesbravo@gmail.com>
0 siblings, 2 replies; 33+ messages in thread
From: shihao zhong @ 2026-10-04 19:14 UTC (permalink / raw)
To: Manu <manuelreyesbravo@gmail.com>; +Cc: Andres Freund <andres@anarazel.de>; Tom Lane <tgl@sss.pgh.pa.us>; Alexandre Felipe <o.alexandre.felipe@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi Manu,
I think v7 is correct, but I am not sure it is better than v6.
v6 keeps the shortcut for both protocols. Its cost is that a
pipelined ALTER commits on its own, but I think few people send
SET TABLESPACE in a pipeline with other statements.
v7 changes no behavior, but the shortcut only helps simple protocol
clients. For me that means mostly psql.
I personally prefer v6, but that should be a committer decision.
Thanks,
Shihao
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-04 20:04 Manu <manuelreyesbravo@gmail.com>
parent: shihao zhong <zhong950419@gmail.com>
1 sibling, 0 replies; 33+ messages in thread
From: Manu @ 2026-10-04 20:04 UTC (permalink / raw)
To: shihao zhong <zhong950419@gmail.com>; +Cc: Andres Freund <andres@anarazel.de>; Tom Lane <tgl@sss.pgh.pa.us>; Alexandre Felipe <o.alexandre.felipe@gmail.com>; pgsql-hackers@lists.postgresql.org
Hi Shihao,
> v6 keeps the shortcut for both protocols. Its cost is that a
> pipelined ALTER commits on its own, but I think few people send
> SET TABLESPACE in a pipeline with other statements.
It does not take an explicit pipeline. I ran an ALTER TABLE SET
TABLESPACE followed by a failing statement, in autocommit mode,
through the batch API of some common drivers. Where the table ends
up, on master, v6 and v7:
pgJDBC 42.7.13, executeBatch(): pg_default, ts, pg_default
pgx 5.11.0, SendBatch(): pg_default, ts, pg_default
psycopg 3.3.6, pipeline(): pg_default, ts, pg_default
On master each batch is one transaction and the failure rolls the
move back. With v6 the move stays, and the application only sees the
error of the later statement. postgres.js, node-postgres and
tokio-postgres send each statement in its own transaction, so they
see no difference.
> v7 changes no behavior, but the shortcut only helps simple protocol
> clients. For me that means mostly psql.
Agreed, the shortcut is narrower. But v7's cost is time only: the
indexes are copied, which made the ALTER at most about 1.7 times
slower in my tests. And the fix is meant for every branch from
REL_14 on, where a minor release should not change what a failed
batch leaves behind.
> I personally prefer v6, but that should be a committer decision.
Agreed. To sum it up for whoever decides: v6 is faster through the
extended protocol and commits the ALTER of a batch on its own; v7
keeps the current behavior and copies the indexes there.
Regards,
Manu
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-14 06:04 Manu <manuelreyesbravo@gmail.com>
parent: shihao zhong <zhong950419@gmail.com>
1 sibling, 1 reply; 33+ messages in thread
From: Manu @ 2026-10-14 06:04 UTC (permalink / raw)
To: shihao zhong <zhong950419@gmail.com>; +Cc: Alexandre Felipe <o.alexandre.felipe@gmail.com>; Andres Freund <andres@anarazel.de>; Tom Lane <tgl@sss.pgh.pa.us>; Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
Hi Shihao,
> I personally prefer v6, but that should be a committer decision.
Agreed it's a committer call. I'm re-attaching v7 as a plain .patch: it
went as .patch.txt before, which the cfbot skips, so the entry was still
testing the first version. No code change -- it applies unchanged on
today's master and REL_19 through REL_14, make check and the standby,
crash and wal_level=minimal checks are green on each, and the regression
test fails without the fix.
For whoever makes the v6-vs-v7 call, the two differ only in the shortcut
that skips copying the indexes:
- v6 keeps the shortcut for both protocols. Its cost is that a
SET TABLESPACE that is the first statement of a pipeline commits on
its own, so a later failure in that pipeline no longer rolls the
move back.
- v7 drops that. The shortcut then applies only to a single-statement
simple-protocol query, so through the extended protocol (most
drivers) the ALTER always copies the indexes: more WAL and one lock
per index, and it can fail if the destination tablespace has room
for the heap but not for the indexes.
I attached v7, as the one that changes no behavior, but either is ready
and I'm happy to switch to v6 if that's the preference.
One user-visible change in both: when the indexes are copied, the
destination tablespace has to have room for them too, not just the heap.
Regards,
Manu
From 34e43bd34d4761296d2008f08a7f7fe28dd04f22 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sun, 4 Oct 2026 04:12:30 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index d4bc38b66e8..22ef44218f7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -94,6 +94,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -611,7 +612,11 @@ static void ATExecSetAccessMethodNoStorage(Relation rel, Oid newAccessMethodId);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5900,7 +5905,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -15288,13 +15295,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -15312,6 +15322,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -15358,6 +15369,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -15365,12 +15384,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 696fd18575c..085dde9cc41 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2387,6 +2387,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 29ef8beaa81..f0401102a1a 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1309,6 +1309,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 180d5a2d227..416d410b476 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -88,6 +88,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 9aa1de38790..378e28d1b76 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 9c561395970..47c24b47a38 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -949,6 +949,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 0f9c136d317..1008038d556 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
From 2dc2eb05ee4be63e65b90792b1d9f8d452534b83 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sun, 4 Oct 2026 04:12:30 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f955813cbf0..503fd6a94a1 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -594,7 +595,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5732,7 +5737,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14818,13 +14825,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14842,6 +14852,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14888,6 +14899,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14895,12 +14914,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 7ba3e283dc3..f29df61c486 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2390,6 +2390,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 570802ff26b..3a63bb09ca6 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1320,6 +1320,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index e529e9f06c2..d4e99350eeb 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -90,6 +90,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 59e64aea073..e2c594a9ee3 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 9c561395970..47c24b47a38 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -949,6 +949,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 0f9c136d317..1008038d556 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
From 81359a60c199ac22e9d86e2cf45e2b258c67ea8d Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:47:53 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails, it
leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its pre-
transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 150 +++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 153 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 102 +++++++++++++++
7 files changed, 420 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..8ffb77da954 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -593,7 +594,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5670,7 +5675,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14791,13 +14798,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14815,6 +14825,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14861,6 +14872,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14868,12 +14887,133 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero
+ * after a rollback to a savepoint even though the file is new; then this
+ * falls back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 2ae682e2221..2300f5c71f7 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2324,6 +2324,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index dbbe5904256..478fb5c0071 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1316,6 +1316,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index d7d307bbfab..f21da20aaa8 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -96,6 +96,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index f9daf5b744c..3123d4aa8dd 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..1fd24491262 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..39669a5a14b 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
From 386bf01164f64a716225594545447bdf8fdf8b64 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:50:40 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails, it
leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its pre-
transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 150 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/input/tablespace.source | 102 +++++++++++++++
src/test/regress/output/tablespace.source | 153 ++++++++++++++++++++++
7 files changed, 420 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..77e7bc4d105 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -89,6 +89,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -549,7 +550,11 @@ static void ATExecDropCluster(Relation rel, LOCKMODE lockmode);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5582,7 +5587,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
}
@@ -14198,13 +14205,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14222,6 +14232,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14270,6 +14281,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14277,12 +14296,133 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero
+ * after a rollback to a savepoint even though the file is new; then this
+ * falls back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 0e64b04bd16..34b82cbb24b 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2309,6 +2309,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 51509efbfc1..cbfd800ad35 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1308,6 +1308,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 00da5e66e70..e85e47b241c 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -90,6 +90,7 @@ extern void PostgresMain(int argc, char *argv[],
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 212e9b32806..7cd9a891d2b 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..a6d699f4b65 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..5369b98c8de 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
Attachments:
[text/x-patch] v7-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch (26.8K, ../../179195789433.990372.6387431139267045114@gmail.com/2-v7-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch)
download | inline diff:
From 447470d8ce1d2e6804a3a69eb8304621bd47982a Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Thu, 1 Oct 2026 21:37:04 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 93ad49e59fd..1cc8b42fd07 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -98,6 +98,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -693,7 +694,11 @@ static void ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel,
bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -6098,7 +6103,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -17529,13 +17536,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -17553,6 +17563,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -17599,6 +17610,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -17606,12 +17625,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b6bdfe213fe..1663cf7aea3 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2457,6 +2457,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 1512d2df196..35f248895d4 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1312,6 +1312,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 5bc5bcfb20d..99e83189946 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -86,6 +86,7 @@ pg_noreturn extern void PostgresMain(const char *dbname,
const char *username);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index abbdb401bf6..0357550da71 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index f0dd25cdf0c..349a2d3a9b1 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index c43a59e5957..e210409fb00 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
[text/plain] us-ascii''v7-REL_17-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (26.8K, ../../179195789433.990372.6387431139267045114@gmail.com/3-us-ascii%27%27v7-REL_17-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 34e43bd34d4761296d2008f08a7f7fe28dd04f22 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sun, 4 Oct 2026 04:12:30 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index d4bc38b66e8..22ef44218f7 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -94,6 +94,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -611,7 +612,11 @@ static void ATExecSetAccessMethodNoStorage(Relation rel, Oid newAccessMethodId);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5900,7 +5905,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -15288,13 +15295,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -15312,6 +15322,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -15358,6 +15369,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -15365,12 +15384,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 696fd18575c..085dde9cc41 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2387,6 +2387,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 29ef8beaa81..f0401102a1a 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1309,6 +1309,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 180d5a2d227..416d410b476 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -88,6 +88,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 9aa1de38790..378e28d1b76 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 9c561395970..47c24b47a38 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -949,6 +949,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 0f9c136d317..1008038d556 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
[text/plain] us-ascii''v7-REL_16-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (26.8K, ../../179195789433.990372.6387431139267045114@gmail.com/4-us-ascii%27%27v7-REL_16-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 2dc2eb05ee4be63e65b90792b1d9f8d452534b83 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Sun, 4 Oct 2026 04:12:30 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched, and that sent through the extended query protocol it
gives them new files; that combined with a CHECK constraint whose
validation on an inheritance child writes to the table after the move
and fails, it leaves no stale index entry; that in a transaction block a
rollback after inserting or truncating leaves the index file at its
pre-transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 152 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 164 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 108 +++++++++++++++
7 files changed, 439 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f955813cbf0..503fd6a94a1 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -594,7 +595,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5732,7 +5737,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14818,13 +14825,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
RelFileNumber newrelfilenumber;
RelFileLocator newrlocator;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14842,6 +14852,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14888,6 +14899,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilelocator(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenumber below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14895,12 +14914,135 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite
+ * fate across commit and abort. See
+ * ATExecSetTableSpaceNewIndexRelfilenumber.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenumber(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenumber within its existing
+ * tablespace, copying the current index file to the new relfilenumber.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenumber(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ RelFileNumber newrelfilenumber;
+ RelFileLocator newrlocator;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilelocatorSubid can be
+ * zero after a rollback to a savepoint even though the file is new; then
+ * this falls back to rd_createSubid, and copies if that does not match
+ * either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilelocatorSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenumber in the index's current tablespace. */
+ newrelfilenumber = GetNewRelFileNumber(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrlocator = ind->rd_locator;
+ newrlocator.relNumber = newrelfilenumber;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrlocator);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenumber;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilelocator(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 7ba3e283dc3..f29df61c486 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2390,6 +2390,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 570802ff26b..3a63bb09ca6 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1320,6 +1320,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index e529e9f06c2..d4e99350eeb 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -90,6 +90,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 59e64aea073..e2c594a9ee3 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 9c561395970..47c24b47a38 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -949,6 +949,170 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 0f9c136d317..1008038d556 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -420,6 +420,114 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- Sent through the extended query protocol, a later statement of the same
+-- pipeline could share its transaction, so the indexes get new files.
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default \bind \g
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
[text/plain] us-ascii''v7-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (25.8K, ../../179195789433.990372.6387431139267045114@gmail.com/5-us-ascii%27%27v7-REL_15-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 81359a60c199ac22e9d86e2cf45e2b258c67ea8d Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:47:53 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails, it
leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its pre-
transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 150 +++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/expected/tablespace.out | 153 +++++++++++++++++++++++
src/test/regress/sql/tablespace.sql | 102 +++++++++++++++
7 files changed, 420 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 3fd375feffc..8ffb77da954 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -92,6 +92,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -593,7 +594,11 @@ static void ATPrepSetAccessMethod(AlteredTableInfo *tab, Relation rel, const cha
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5670,7 +5675,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
/*
@@ -14791,13 +14798,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14815,6 +14825,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14861,6 +14872,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14868,12 +14887,133 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero
+ * after a rollback to a savepoint even though the file is new; then this
+ * falls back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 2ae682e2221..2300f5c71f7 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2324,6 +2324,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index dbbe5904256..478fb5c0071 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1316,6 +1316,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index d7d307bbfab..f21da20aaa8 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -96,6 +96,7 @@ extern void PostgresMain(const char *dbname,
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index f9daf5b744c..3123d4aa8dd 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/expected/tablespace.out b/src/test/regress/expected/tablespace.out
index 3873e3cd7fc..1fd24491262 100644
--- a/src/test/regress/expected/tablespace.out
+++ b/src/test/regress/expected/tablespace.out
@@ -951,6 +951,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/sql/tablespace.sql b/src/test/regress/sql/tablespace.sql
index 060698072e6..39669a5a14b 100644
--- a/src/test/regress/sql/tablespace.sql
+++ b/src/test/regress/sql/tablespace.sql
@@ -422,6 +422,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
[text/plain] us-ascii''v7-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt (25.8K, ../../179195789433.990372.6387431139267045114@gmail.com/6-us-ascii%27%27v7-REL_14-0001-Fix-index-corruption-after-rolling-back-ALTER-TAB.patch.txt)
download | inline diff:
From 386bf01164f64a716225594545447bdf8fdf8b64 Mon Sep 17 00:00:00 2001
From: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Date: Fri, 2 Oct 2026 10:50:40 -0300
Subject: [PATCH v7] Fix index corruption after rolling back ALTER TABLE SET
TABLESPACE
ALTER TABLE ... SET TABLESPACE rewrites a table's heap to a new
relfilenode but leaves the table's indexes on their existing
relfilenodes. The two then roll back by different mechanisms: on abort
the heap's new file is discarded, freeing the heap TIDs consumed by rows
inserted after the SET TABLESPACE, while the index entries for those rows
were written to the unchanged index files and survive. A later insert
can reuse a freed heap TID, leaving two index entries that point at the
same live heap tuple. This surfaces as a _bt_posting_valid assertion
failure in nbtree deduplication, and as duplicate rows through an
index-only scan in gist. A TRUNCATE after the move in the same
transaction truncates the old index files in place, which a rollback
cannot undo either.
Fix by giving each index of the table a new relfilenumber in the same
ALTER, copying it within its own tablespace (the indexes stay where they
are, as documented), so that an abort discards the new heap and index
files together. Two cases need no copy:
- The ALTER is a plain ALTER TABLE ... SET TABLESPACE of a single
table, with no other subcommand, sent by a client as the only
statement of a simple Query message outside a transaction block, so
the transaction ends right after it. Any other subcommand could run
user code after the move: the validation of an inheritance child's
new CHECK constraint, a sql_drop event trigger, and so on. This also
requires that no ddl_command_end event trigger runs after it. A
statement sent through the extended query protocol always copies: a
later statement of the same pipeline shares its transaction until
the next Sync, and the backend cannot tell whether one will follow.
IsExtendedQueryMessage() is added to postgres.c to tell the two
apart.
- The index's current file was created in the current subtransaction,
for example an index created, or moved, earlier in it. An abort
discards that file together with the heap's new one. This also lets
a transaction move the indexes first and then the table without
copying the indexes twice.
The regression test checks that the ALTER on its own leaves the index
files untouched; that combined with a CHECK constraint whose validation
on an inheritance child writes to the table after the move and fails, it
leaves no stale index entry; that in a transaction block a rollback
after inserting or truncating leaves the index file at its pre-
transaction size; that an index created or moved earlier in the
subtransaction is not copied; and that after a rollback to a savepoint,
an index created, reindexed or already copied before the savepoint
returns no row for the rolled-back values.
Bug: #19686
Reported-by: Alexander Lakhin
Suggested-by: Andres Freund
Reviewed-by: Alexandre Felipe
Reviewed-by: Shihao Zhong
---
src/backend/commands/tablecmds.c | 150 ++++++++++++++++++++-
src/backend/tcop/postgres.c | 17 +++
src/backend/tcop/utility.c | 1 +
src/include/tcop/tcopprot.h | 1 +
src/include/tcop/utility.h | 1 +
src/test/regress/input/tablespace.source | 102 +++++++++++++++
src/test/regress/output/tablespace.source | 153 ++++++++++++++++++++++
7 files changed, 420 insertions(+), 5 deletions(-)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index e445b801a6b..77e7bc4d105 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -89,6 +89,7 @@
#include "tcop/utility.h"
#include "utils/acl.h"
#include "utils/builtins.h"
+#include "utils/evtcache.h"
#include "utils/fmgroids.h"
#include "utils/inval.h"
#include "utils/lsyscache.h"
@@ -549,7 +550,11 @@ static void ATExecDropCluster(Relation rel, LOCKMODE lockmode);
static bool ATPrepChangePersistence(Relation rel, bool toLogged);
static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
const char *tablespacename, LOCKMODE lockmode);
-static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
+static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes);
+static bool ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context);
+static void ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode);
static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
static void ATExecSetRelOptions(Relation rel, List *defList,
AlterTableType operation,
@@ -5582,7 +5587,9 @@ ATRewriteTables(AlterTableStmt *parsetree, List **wqueue, LOCKMODE lockmode,
* just do a block-by-block copy.
*/
if (tab->newTableSpace)
- ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode);
+ ATExecSetTableSpace(tab->relid, tab->newTableSpace, lockmode,
+ ATSetTableSpaceCopyIndexes(parsetree, *wqueue,
+ context));
}
}
@@ -14198,13 +14205,16 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
* rewriting to be done, so we just want to copy the data as fast as possible.
*/
static void
-ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
+ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode,
+ bool copyIndexes)
{
Relation rel;
Oid reltoastrelid;
+ char relkind;
Oid newrelfilenode;
RelFileNode newrnode;
List *reltoastidxids = NIL;
+ List *reltabidxids = NIL;
ListCell *lc;
/*
@@ -14222,6 +14232,7 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
}
reltoastrelid = rel->rd_rel->reltoastrelid;
+ relkind = rel->rd_rel->relkind;
/* Fetch the list of indexes on toast relation if necessary */
if (OidIsValid(reltoastrelid))
{
@@ -14270,6 +14281,14 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
RelationAssumeNewRelfilenode(rel);
+ /*
+ * If this is a table, collect its index list now, while the relation is
+ * still open, so we can give each index a fresh relfilenode below.
+ */
+ if (copyIndexes &&
+ (relkind == RELKIND_RELATION || relkind == RELKIND_MATVIEW))
+ reltabidxids = RelationGetIndexList(rel);
+
relation_close(rel, NoLock);
/* Make sure the reltablespace change is visible */
@@ -14277,12 +14296,133 @@ ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode)
/* Move associated toast relation and/or indexes, too */
if (OidIsValid(reltoastrelid))
- ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode);
+ ATExecSetTableSpace(reltoastrelid, newTableSpace, lockmode, copyIndexes);
foreach(lc, reltoastidxids)
- ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode);
+ ATExecSetTableSpace(lfirst_oid(lc), newTableSpace, lockmode, copyIndexes);
/* Clean up */
list_free(reltoastidxids);
+
+ /*
+ * The heap now has a new relfilenode. Give each of the table's indexes a
+ * fresh relfilenode too, so that the indexes share the heap's rewrite fate
+ * across commit and abort. See ATExecSetTableSpaceNewIndexRelfilenode.
+ */
+ foreach(lc, reltabidxids)
+ ATExecSetTableSpaceNewIndexRelfilenode(lfirst_oid(lc), lockmode);
+ list_free(reltabidxids);
+}
+
+/*
+ * Does ALTER TABLE SET TABLESPACE need to give the table's indexes new files?
+ *
+ * Only if something can still modify the table in the same transaction after
+ * the move. Rather than listing every place where user code can run after
+ * it -- the validation of other tables in the work queue, such as an
+ * inheritance child getting a new CHECK constraint, foreign key validation,
+ * sql_drop and table_rewrite event triggers, and more -- the files are kept
+ * only for the one case where none of them exists: a plain
+ * "ALTER TABLE ... SET TABLESPACE" of a single table, with no other
+ * subcommand, sent by a client as the only statement of a simple Query
+ * message, outside any transaction block: the transaction then ends right
+ * after it. What can still run after it is a ddl_command_end event trigger,
+ * so that case also requires that there is none.
+ *
+ * A statement sent by an extended-protocol Execute message always copies:
+ * later statements of the same pipeline share its transaction until the next
+ * Sync, and the backend cannot tell whether any will follow. Forcing a commit
+ * after the ALTER would make it commit on its own when a later statement of
+ * the pipeline fails, which is not how ALTER TABLE behaves otherwise.
+ */
+static bool
+ATSetTableSpaceCopyIndexes(AlterTableStmt *parsetree, List *wqueue,
+ AlterTableUtilityContext *context)
+{
+ if (context == NULL || IsInTransactionBlock(context->isTopLevel))
+ return true;
+ if (MyBackendType != B_BACKEND || IsExtendedQueryMessage())
+ return true;
+ if (parsetree == NULL || list_length(parsetree->cmds) != 1 ||
+ castNode(AlterTableCmd, linitial(parsetree->cmds))->subtype != AT_SetTableSpace ||
+ list_length(wqueue) != 1)
+ return true;
+ if (EventCacheLookup(EVT_DDLCommandEnd) != NIL)
+ return true;
+
+ return false;
+}
+
+/*
+ * Give one of a table's indexes a fresh relfilenode within its existing
+ * tablespace, copying the current index file to the new relfilenode.
+ *
+ * ATExecSetTableSpace() calls this for each index of a table whose heap it has
+ * just rewritten to a new relfilenode. The indexes must share the heap's
+ * transactional fate: an abort has to discard the index entries written during
+ * the transaction together with the heap's new file, since the heap TIDs those
+ * entries point at are freed by the abort and can be reused by later inserts.
+ * Copying the existing index file keeps the added cost close to that of the
+ * heap move itself; the index stays in its own tablespace, as documented.
+ */
+static void
+ATExecSetTableSpaceNewIndexRelfilenode(Oid indexOid, LOCKMODE lockmode)
+{
+ Relation ind;
+ Oid newrelfilenode;
+ RelFileNode newrnode;
+ Relation pg_class;
+ HeapTuple tuple;
+ ItemPointerData otid;
+ Form_pg_class rd_rel;
+
+ ind = relation_open(indexOid, lockmode);
+
+ /*
+ * Only plain indexes have storage that can hold the stale entries. An
+ * index whose current file was created in this subtransaction (the index
+ * itself, or a new file for it) is discarded together with the heap's new
+ * file on abort, so it needs no copy. rd_newRelfilenodeSubid can be zero
+ * after a rollback to a savepoint even though the file is new; then this
+ * falls back to rd_createSubid, and copies if that does not match either.
+ */
+ if (ind->rd_rel->relkind != RELKIND_INDEX ||
+ !RELKIND_HAS_STORAGE(ind->rd_rel->relkind) ||
+ Max(ind->rd_newRelfilenodeSubid, ind->rd_createSubid) ==
+ GetCurrentSubTransactionId())
+ {
+ relation_close(ind, NoLock);
+ return;
+ }
+
+ /* Allocate a new relfilenode in the index's current tablespace. */
+ newrelfilenode = GetNewRelFileNode(ind->rd_rel->reltablespace, NULL,
+ ind->rd_rel->relpersistence);
+ newrnode = ind->rd_node;
+ newrnode.relNode = newrelfilenode;
+
+ /* Copy the index into the new file and schedule the old one for cleanup. */
+ index_copy_data(ind, newrnode);
+
+ /* Update the pg_class row; only the relfilenode changes. */
+ pg_class = table_open(RelationRelationId, RowExclusiveLock);
+ tuple = SearchSysCacheLockedCopy1(RELOID, ObjectIdGetDatum(indexOid));
+ if (!HeapTupleIsValid(tuple))
+ elog(ERROR, "cache lookup failed for index %u", indexOid);
+ otid = tuple->t_self;
+ rd_rel = (Form_pg_class) GETSTRUCT(tuple);
+ rd_rel->relfilenode = newrelfilenode;
+ CatalogTupleUpdate(pg_class, &otid, tuple);
+ UnlockTuple(pg_class, &otid, InplaceUpdateTupleLock);
+ heap_freetuple(tuple);
+ table_close(pg_class, RowExclusiveLock);
+
+ InvokeObjectPostAlterHook(RelationRelationId, indexOid, 0);
+ RelationAssumeNewRelfilenode(ind);
+
+ relation_close(ind, NoLock);
+
+ /* Make the relfilenode change visible. */
+ CommandCounterIncrement();
}
/*
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 0e64b04bd16..34b82cbb24b 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2309,6 +2309,23 @@ check_log_statement(List *stmt_list)
return false;
}
+/*
+ * IsExtendedQueryMessage
+ * Is the current statement being run by an extended-query-protocol
+ * message?
+ *
+ * Such a statement shares its implicit transaction with whatever else the
+ * client sends before the next Sync, which the backend cannot know while it
+ * runs the statement. The first statement of a pipeline cannot be told from
+ * one followed directly by Sync: XACT_FLAGS_PIPELINING is set only once an
+ * Execute completes.
+ */
+bool
+IsExtendedQueryMessage(void)
+{
+ return doing_extended_query_message;
+}
+
/*
* check_log_duration
* Determine whether current command's duration should be logged
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 51509efbfc1..cbfd800ad35 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -1308,6 +1308,7 @@ ProcessUtilitySlow(ParseState *pstate,
atcontext.relid = relid;
atcontext.params = params;
atcontext.queryEnv = queryEnv;
+ atcontext.isTopLevel = isTopLevel;
/* ... ensure we have an event trigger context ... */
EventTriggerAlterTableStart(parsetree);
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 00da5e66e70..e85e47b241c 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -90,6 +90,7 @@ extern void PostgresMain(int argc, char *argv[],
extern long get_stack_depth_rlimit(void);
extern void ResetUsage(void);
extern void ShowUsage(const char *title);
+extern bool IsExtendedQueryMessage(void);
extern int check_log_duration(char *msec_str, bool was_logged);
extern void set_debug_options(int debug_flag,
GucContext context, GucSource source);
diff --git a/src/include/tcop/utility.h b/src/include/tcop/utility.h
index 212e9b32806..7cd9a891d2b 100644
--- a/src/include/tcop/utility.h
+++ b/src/include/tcop/utility.h
@@ -34,6 +34,7 @@ typedef struct AlterTableUtilityContext
Oid relid; /* OID of ALTER's target table */
ParamListInfo params; /* any parameters available to ALTER TABLE */
QueryEnvironment *queryEnv; /* execution environment for ALTER TABLE */
+ bool isTopLevel; /* ALTER TABLE is a top-level statement */
} AlterTableUtilityContext;
/*
diff --git a/src/test/regress/input/tablespace.source b/src/test/regress/input/tablespace.source
index fd003d805e7..a6d699f4b65 100644
--- a/src/test/regress/input/tablespace.source
+++ b/src/test/regress/input/tablespace.source
@@ -400,6 +400,108 @@ REINDEX (TABLESPACE regress_tblspace) TABLE tablespace_table; -- fail
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+SELECT count(*) FROM tbspace_rollback;
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+SELECT count(*) FROM tbspace_subxact;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
+
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
diff --git a/src/test/regress/output/tablespace.source b/src/test/regress/output/tablespace.source
index 1b60b99ff3e..5369b98c8de 100644
--- a/src/test/regress/output/tablespace.source
+++ b/src/test/regress/output/tablespace.source
@@ -921,6 +921,159 @@ ERROR: permission denied for tablespace regress_tblspace
REINDEX (TABLESPACE regress_tblspace, CONCURRENTLY) TABLE tablespace_table; -- fail
ERROR: permission denied for tablespace regress_tblspace
RESET ROLE;
+-- ALTER TABLE SET TABLESPACE moves the heap and leaves the indexes in their
+-- tablespace. Run on its own, as here, it leaves their files alone too.
+CREATE TABLE tbspace_rollback (a int);
+INSERT INTO tbspace_rollback SELECT generate_series(1, 100);
+CREATE INDEX tbspace_rollback_idx ON tbspace_rollback (a);
+SELECT pg_relation_size('tbspace_rollback_idx') AS idx_size_before \gset
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') = :'idx_path' AS idx_file_kept;
+ idx_file_kept
+---------------
+ t
+(1 row)
+
+-- In a transaction block the indexes get new files, so that a rollback
+-- discards the entries added after the move together with the heap's file.
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx') <> :'idx_path' AS idx_file_new;
+ idx_file_new
+--------------
+ t
+(1 row)
+
+INSERT INTO tbspace_rollback SELECT generate_series(101, 2000);
+SELECT pg_relation_size('tbspace_rollback_idx') > :idx_size_before AS idx_grew;
+ idx_grew
+----------
+ t
+(1 row)
+
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+-- the same for a TRUNCATE after the move, which is done in place
+BEGIN;
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+TRUNCATE tbspace_rollback;
+ROLLBACK;
+SELECT pg_relation_size('tbspace_rollback_idx') = :idx_size_before AS idx_size_restored;
+ idx_size_restored
+-------------------
+ t
+(1 row)
+
+SELECT count(*) FROM tbspace_rollback;
+ count
+-------
+ 100
+(1 row)
+
+-- An index created in the same subtransaction is discarded with the heap
+-- anyway and is not copied. Moving the indexes first and then the table
+-- copies each index only once.
+BEGIN;
+CREATE INDEX tbspace_rollback_idx2 ON tbspace_rollback (a);
+SELECT pg_relation_filepath('tbspace_rollback_idx2') AS idx2_path \gset
+ALTER INDEX tbspace_rollback_idx SET TABLESPACE regress_tblspace;
+SELECT pg_relation_filepath('tbspace_rollback_idx') AS idx_moved_path \gset
+ALTER TABLE tbspace_rollback SET TABLESPACE pg_default;
+SELECT pg_relation_filepath('tbspace_rollback_idx2') = :'idx2_path' AS idx2_not_copied,
+ pg_relation_filepath('tbspace_rollback_idx') = :'idx_moved_path' AS idx_not_copied_again;
+ idx2_not_copied | idx_not_copied_again
+-----------------+----------------------
+ t | t
+(1 row)
+
+ROLLBACK;
+DROP TABLE tbspace_rollback;
+-- An index file that is new in this transaction but older than a savepoint
+-- survives a rollback to it, so it must still follow a move made after the
+-- savepoint: an index created or reindexed before it, or already copied by
+-- an earlier move. Rows inserted afterwards reuse the TIDs freed by the
+-- rollbacks; a stale index entry would match them.
+CREATE TABLE tbspace_subxact (a int);
+INSERT INTO tbspace_subxact SELECT generate_series(1, 100);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+BEGIN;
+CREATE INDEX tbspace_subxact_idx ON tbspace_subxact (a);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+REINDEX INDEX tbspace_subxact_idx;
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+BEGIN;
+ALTER TABLE tbspace_subxact SET TABLESPACE regress_tblspace;
+INSERT INTO tbspace_subxact VALUES (0);
+SAVEPOINT s;
+ALTER TABLE tbspace_subxact SET TABLESPACE pg_default;
+INSERT INTO tbspace_subxact SELECT -generate_series(1, 50);
+ROLLBACK TO s;
+COMMIT;
+INSERT INTO tbspace_subxact SELECT generate_series(101, 300);
+SELECT count(*) FROM tbspace_subxact WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT count(*) FROM tbspace_subxact;
+ count
+-------
+ 301
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_subxact;
+-- Combined with another subcommand, the ALTER can run user code after the
+-- move, such as a CHECK constraint validated on an inheritance child after
+-- the parent was moved, so the indexes get new files even outside a
+-- transaction block.
+CREATE TABLE tbspace_combined (a int);
+INSERT INTO tbspace_combined SELECT generate_series(1, 100);
+CREATE INDEX tbspace_combined_idx ON tbspace_combined (a);
+CREATE TABLE tbspace_combined_child () INHERITS (tbspace_combined);
+INSERT INTO tbspace_combined_child VALUES (5000);
+CREATE FUNCTION tbspace_combined_chk(v int) RETURNS bool LANGUAGE plpgsql AS $$
+BEGIN
+ IF v < 5000 THEN RETURN true; END IF;
+ INSERT INTO tbspace_combined SELECT -generate_series(1, 50);
+ RAISE EXCEPTION 'abort after writing';
+END $$;
+ALTER TABLE tbspace_combined SET TABLESPACE regress_tblspace,
+ ADD CONSTRAINT tbspace_combined_k CHECK (tbspace_combined_chk(a));
+ERROR: abort after writing
+CONTEXT: PL/pgSQL function tbspace_combined_chk(integer) line 5 at RAISE
+DROP TABLE tbspace_combined_child;
+INSERT INTO tbspace_combined SELECT generate_series(101, 300);
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM tbspace_combined WHERE a < 0;
+ count
+-------
+ 0
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE tbspace_combined;
+DROP FUNCTION tbspace_combined_chk(int);
ALTER TABLESPACE regress_tblspace RENAME TO regress_tblspace_renamed;
ALTER TABLE ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
ALTER INDEX ALL IN TABLESPACE regress_tblspace_renamed SET TABLESPACE pg_default;
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-18 04:39 shihao zhong <zhong950419@gmail.com>
parent: Manu <manuelreyesbravo@gmail.com>
0 siblings, 1 reply; 33+ messages in thread
From: shihao zhong @ 2026-10-18 04:39 UTC (permalink / raw)
To: Manu <manuelreyesbravo@gmail.com>; +Cc: Alexandre Felipe <o.alexandre.felipe@gmail.com>; Andres Freund <andres@anarazel.de>; Tom Lane <tgl@sss.pgh.pa.us>; Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
Hi Manu,
One correction. v7 copies the indexes in their own tablespace, so the
room is needed on the old volume, not the destination.
That means a driver sending a single ALTER fails when the old volume
is nearly full, while master works:
master: ALTER TABLE
v7: ERROR: could not extend file "pg_tblspc/...": No space
left on device
Thanks,
Shihao
^ permalink raw reply [nested|flat] 33+ messages in thread
* Re: BUG #19686: Rolling back SET TABLESPACE
@ 2026-10-18 16:45 Manu <manuelreyesbravo@gmail.com>
parent: shihao zhong <zhong950419@gmail.com>
0 siblings, 0 replies; 33+ messages in thread
From: Manu @ 2026-10-18 16:45 UTC (permalink / raw)
To: shihao zhong <zhong950419@gmail.com>; +Cc: Alexandre Felipe <o.alexandre.felipe@gmail.com>; Andres Freund <andres@anarazel.de>; Tom Lane <tgl@sss.pgh.pa.us>; Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org
Hi Shihao,
> One correction. v7 copies the indexes in their own tablespace, so the
> room is needed on the old volume, not the destination.
You're right -- thanks for catching it. My earlier "destination tablespace"
was wrong, and I measured it. v7 copies each index into the index's own tablespace, so that is where the
extra room is needed; the destination only has to fit the heap, the same as
master. The extra is the size of the indexes, within about 32 kB: 6.45 MB for
one index, 12.89 MB for two, 25.78 MB for four in my test. A single ALTER
through the extended protocol then fails with ENOSPC on a nearly-full index
volume where master succeeds; inside a transaction block v6 copies too, so
that cost is v7's only for the single extended-protocol statement.
This is the cost the deferred copy (my v4) avoided -- it copied nothing for an
ALTER with no later write, 0 copies across the 15 cases I re-ran. Since the
cost is back, I went back to the two objections that led me to drop it.
Andres, on the old index being pinned: I measured it in 13 scenarios where the
deferred copy runs with a buffer of the old index file still pinned, verified
each time with pg_buffercache before the write. (A plain MVCC btree scan drops
the leaf pin, so the outer scans are index-only or hash, which keep it, plus a
nested loop, a BEFORE trigger, a scrollable cursor, a generic prepared plan, a
partial index and writes under savepoints.) All 13 are correct after COMMIT
and ROLLBACK; master fails every one on ROLLBACK. The oracle is index-vs-seq,
by row md5 over 8 query shapes and index-only scans after VACUUM to surface
stale entries, since amcheck's heapallindexed only checks heap->index. One v4
case does fail -- a REINDEX before a savepoint whose move is then rolled back
-- but that is the savepoint gap v5 already closed, not a pin issue, so a
deferred v8 would keep v5's per-subtransaction rule. This was btree and hash,
one backend.
On the other objection, the performance being harder to reason about, I have
no number that settles it: the copy happens only when a later write makes it
necessary, and the first such write pays for all the indexes, which is a
trade-off to weigh rather than something I can measure away.
For the back branches the deferred copy is a behavior change and, as Michael
noted, not backpatchable, so there the eager copy (v7) is the fix regardless;
the question is only 19 and master.
So, given the space cost: is the deferred copy worth another look for 19, or
should we take the eager copy everywhere? I'm happy to prepare a proper v8 of
it if you'd like to see one, and otherwise v7 stands.
regards,
Manu
^ permalink raw reply [nested|flat] 33+ messages in thread
end of thread, other threads:[~2026-10-18 16:45 UTC | newest]
Thread overview: 33+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-09-16 22:06 BUG #19686: Rolling back SET TABLESPACE Alexandre Felipe <o.alexandre.felipe@gmail.com>
2026-09-27 18:56 ` Alexandre Felipe <o.alexandre.felipe@gmail.com>
2026-09-27 19:09 ` Manu <manuelreyesbravo@gmail.com>
2026-09-29 08:37 ` Alexandre Felipe <o.alexandre.felipe@gmail.com>
2026-09-29 11:45 ` Manu <manuelreyesbravo@gmail.com>
2026-09-29 19:10 ` Alexandre Felipe <o.alexandre.felipe@gmail.com>
2026-09-29 22:23 ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-09-29 21:44 ` Andres Freund <andres@anarazel.de>
2026-09-29 23:39 ` Tom Lane <tgl@sss.pgh.pa.us>
2026-09-30 00:08 ` Michael Paquier <michael@paquier.xyz>
2026-09-30 07:23 ` Alexandre Felipe <o.alexandre.felipe@gmail.com>
2026-09-30 13:40 ` Tom Lane <tgl@sss.pgh.pa.us>
2026-09-30 14:16 ` Alexandre Felipe <o.alexandre.felipe@gmail.com>
2026-10-01 08:20 ` Alexandre Felipe <o.alexandre.felipe@gmail.com>
2026-10-02 00:57 ` Manu <manuelreyesbravo@gmail.com>
2026-10-02 03:27 ` shihao zhong <zhong950419@gmail.com>
2026-10-02 08:35 ` Alexandre Felipe <o.alexandre.felipe@gmail.com>
2026-10-02 14:36 ` Manu <manuelreyesbravo@gmail.com>
2026-10-02 17:06 ` Alexandre Felipe <o.alexandre.felipe@gmail.com>
2026-10-02 17:18 ` Manu <manuelreyesbravo@gmail.com>
2026-10-02 18:52 ` Andres Freund <andres@anarazel.de>
2026-10-02 19:34 ` Manu <manuelreyesbravo@gmail.com>
2026-10-03 04:26 ` shihao zhong <zhong950419@gmail.com>
2026-10-03 14:39 ` Manu <manuelreyesbravo@gmail.com>
2026-10-04 04:22 ` shihao zhong <zhong950419@gmail.com>
2026-10-04 08:25 ` Manu <manuelreyesbravo@gmail.com>
2026-10-04 19:14 ` shihao zhong <zhong950419@gmail.com>
2026-10-04 20:04 ` Manu <manuelreyesbravo@gmail.com>
2026-10-14 06:04 ` Manu <manuelreyesbravo@gmail.com>
2026-10-18 04:39 ` shihao zhong <zhong950419@gmail.com>
2026-10-18 16:45 ` Manu <manuelreyesbravo@gmail.com>
2026-10-02 18:30 ` Andres Freund <andres@anarazel.de>
2026-10-02 18:55 ` Tom Lane <tgl@sss.pgh.pa.us>
This inbox is served by DDX for PostgreSQL; see mirroring instructions
for how to clone and mirror all data and code used for this inbox