From 6e3eca0e1327de6a768cc094f2b277b53ad17784 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Sat, 22 Aug 2026 11:23:12 +0300
Subject: [PATCH v2 3/3] Use full XIDs in deleted GIN posting-tree pages

GIN stores page deletion horizons as 32-bit XIDs that are not protected
against wraparound.  Once such a horizon is interpreted as belonging to
the next epoch, an otherwise reusable page can remain unrecyclable for
about two billion transactions.

Store a FullTransactionId in newly deleted posting-tree pages and in the
associated WAL record.  Mark the new on-page representation with a flag,
so indexes upgraded from older releases retain the conservative behavior
for old deleted pages until those pages are reused.

This changes the GIN delete-page WAL record and requires an
XLOG_PAGE_MAGIC bump.

Discussion: https://postgr.es/m/CAH2-Wz=sKJcn+OtfVN9rdg+Ps9e4cuQWNP-9t12UE2d8nEG90Q@mail.gmail.com
---
 contrib/pageinspect/ginfuncs.c     |  5 ++++-
 src/backend/access/gin/ginutil.c   | 29 ++++++++++++++++++-----------
 src/backend/access/gin/ginvacuum.c | 15 +++++++--------
 src/backend/access/gin/ginxlog.c   |  7 ++++---
 src/include/access/ginblock.h      | 26 +++++++++++++++++++++++++-
 src/include/access/ginxlog.h       |  2 +-
 6 files changed, 59 insertions(+), 25 deletions(-)

diff --git a/contrib/pageinspect/ginfuncs.c b/contrib/pageinspect/ginfuncs.c
index 058ad52b671..c4f0e3c0697 100644
--- a/contrib/pageinspect/ginfuncs.c
+++ b/contrib/pageinspect/ginfuncs.c
@@ -148,8 +148,11 @@ gin_page_opaque_info(PG_FUNCTION_ARGS)
 		flags[nflags++] = CStringGetTextDatum("incomplete_split");
 	if (flagbits & GIN_COMPRESSED)
 		flags[nflags++] = CStringGetTextDatum("compressed");
+	if (flagbits & GIN_DELETED_FULL_XID)
+		flags[nflags++] = CStringGetTextDatum("deleted_full_xid");
 	flagbits &= ~(GIN_DATA | GIN_LEAF | GIN_DELETED | GIN_META | GIN_LIST |
-				  GIN_LIST_FULLROW | GIN_INCOMPLETE_SPLIT | GIN_COMPRESSED);
+				  GIN_LIST_FULLROW | GIN_INCOMPLETE_SPLIT | GIN_COMPRESSED |
+				  GIN_DELETED_FULL_XID);
 	if (flagbits)
 	{
 		/* any flags we don't recognize are printed in hex */
diff --git a/src/backend/access/gin/ginutil.c b/src/backend/access/gin/ginutil.c
index 434efe78de6..1e4f00a5cbc 100644
--- a/src/backend/access/gin/ginutil.c
+++ b/src/backend/access/gin/ginutil.c
@@ -332,21 +332,28 @@ GinNewBuffer(Relation index)
 				Page		page = BufferGetPage(buffer);
 
 				if (XLogStandbyInfoActive() && RelationNeedsWAL(index) &&
-					GinPageGetDeleteXid(page) != InvalidTransactionId)
+					(GinPageHasFullDeleteXid(page) ||
+					 TransactionIdIsValid(GinPageGetDeleteXid(page))))
 				{
-					FullTransactionId nextfxid = ReadNextFullTransactionId();
 					FullTransactionId deletefxid;
 					Relation	heaprel;
 
-					/*
-					 * Legacy GIN pages only store the low 32 bits of the
-					 * deletion XID.  Interpret those as the latest occurrence
-					 * not later than nextFullXid.  This can give a conservative
-					 * result for a page that survived an entire XID epoch, but
-					 * avoids weakening recovery conflict handling.
-					 */
-					deletefxid = FullTransactionIdFromAllowableAt(
-						nextfxid, GinPageGetDeleteXid(page));
+					if (GinPageHasFullDeleteXid(page))
+						deletefxid = GinPageGetDeleteFullXid(page);
+					else
+					{
+						FullTransactionId nextfxid = ReadNextFullTransactionId();
+
+						/*
+						 * Legacy GIN pages only store the low 32 bits of the
+						 * deletion XID.  Interpret those as the latest occurrence
+						 * not later than nextFullXid.  This can give a conservative
+						 * result for a page that survived an entire XID epoch, but
+						 * avoids weakening recovery conflict handling.
+						 */
+						deletefxid = FullTransactionIdFromAllowableAt(
+							nextfxid, GinPageGetDeleteXid(page));
+					}
 
 					heaprel = table_open(index->rd_index->indrelid, NoLock);
 
diff --git a/src/backend/access/gin/ginvacuum.c b/src/backend/access/gin/ginvacuum.c
index 7a1a1da7fb2..f7ed75c62f8 100644
--- a/src/backend/access/gin/ginvacuum.c
+++ b/src/backend/access/gin/ginvacuum.c
@@ -204,8 +204,7 @@ ginDeletePostingPage(GinVacuumState *gvs, Buffer dBuffer, Buffer lBuffer,
 	 * Mark page as deleted, and remember last xid which could know its
 	 * address.
 	 */
-	GinPageSetDeleted(page);
-	GinPageSetDeleteXid(page, ReadNextTransactionId());
+	GinPageSetDeleteFullXid(page, ReadNextFullTransactionId());
 
 	MarkBufferDirty(pBuffer);
 	MarkBufferDirty(lBuffer);
@@ -231,7 +230,7 @@ ginDeletePostingPage(GinVacuumState *gvs, Buffer dBuffer, Buffer lBuffer,
 
 		data.parentOffset = myoff;
 		data.rightLink = GinPageGetOpaque(page)->rightlink;
-		data.deleteXid = GinPageGetDeleteXid(page);
+		data.deleteXid = GinPageGetDeleteFullXid(page);
 
 		XLogRegisterData(&data, sizeof(ginxlogDeletePage));
 
@@ -896,22 +895,22 @@ ginvacuumcleanup(IndexVacuumInfo *info, IndexBulkDeleteResult *stats)
 bool
 GinPageIsRecyclable(Page page)
 {
-	TransactionId delete_xid;
-
 	if (PageIsNew(page))
 		return true;
 
 	if (!GinPageIsDeleted(page))
 		return false;
 
-	delete_xid = GinPageGetDeleteXid(page);
+	if (GinPageHasFullDeleteXid(page))
+		return GlobalVisCheckRemovableFullXid(NULL,
+										 GinPageGetDeleteFullXid(page));
 
-	if (!TransactionIdIsValid(delete_xid))
+	if (!TransactionIdIsValid(GinPageGetDeleteXid(page)))
 		return true;
 
 	/*
 	 * If no backend still could view delete_xid as in running, all scans
 	 * concurrent with ginDeletePostingPage() must have finished.
 	 */
-	return GlobalVisCheckRemovableXid(NULL, delete_xid);
+	return GlobalVisCheckRemovableXid(NULL, GinPageGetDeleteXid(page));
 }
diff --git a/src/backend/access/gin/ginxlog.c b/src/backend/access/gin/ginxlog.c
index b1fee3c281f..a353a6e235c 100644
--- a/src/backend/access/gin/ginxlog.c
+++ b/src/backend/access/gin/ginxlog.c
@@ -499,8 +499,7 @@ ginRedoDeletePage(XLogReaderState *record)
 	{
 		page = BufferGetPage(dbuffer);
 		Assert(GinPageIsData(page));
-		GinPageSetDeleted(page);
-		GinPageSetDeleteXid(page, data->deleteXid);
+		GinPageSetDeleteFullXid(page, data->deleteXid);
 		PageSetLSN(page, lsn);
 		MarkBufferDirty(dbuffer);
 	}
@@ -804,7 +803,9 @@ gin_mask(char *pagedata, BlockNumber blkno)
 	 * the whole page content.  For other pages, mask the hole if pd_lower
 	 * appears to have been set correctly.
 	 */
-	if (opaque->flags & GIN_DELETED)
+	if (opaque->flags & GIN_DELETED_FULL_XID)
+		mask_unused_space(page);
+	else if (opaque->flags & GIN_DELETED)
 		mask_page_content(page);
 	else if (pagehdr->pd_lower > SizeOfPageHeaderData)
 		mask_unused_space(page);
diff --git a/src/include/access/ginblock.h b/src/include/access/ginblock.h
index 2d75023179a..c15324037b1 100644
--- a/src/include/access/ginblock.h
+++ b/src/include/access/ginblock.h
@@ -47,6 +47,7 @@ typedef GinPageOpaqueData *GinPageOpaque;
 #define GIN_INCOMPLETE_SPLIT (1 << 6)	/* page was split, but parent not
 										 * updated */
 #define GIN_COMPRESSED	  (1 << 7)
+#define GIN_DELETED_FULL_XID (1 << 8) /* deleted page stores a full XID */
 
 /* Page numbers of fixed-location pages */
 #define GIN_METAPAGE_BLKNO	(0)
@@ -126,6 +127,7 @@ typedef struct GinMetaPageData
 #define GinPageSetDeleted(page)    ( GinPageGetOpaque(page)->flags |= GIN_DELETED)
 #define GinPageSetNonDeleted(page) ( GinPageGetOpaque(page)->flags &= ~GIN_DELETED)
 #define GinPageIsIncompleteSplit(page) ( (GinPageGetOpaque(page)->flags & GIN_INCOMPLETE_SPLIT) != 0 )
+#define GinPageHasFullDeleteXid(page) ( (GinPageGetOpaque(page)->flags & GIN_DELETED_FULL_XID) != 0 )
 
 #define GinPageRightMost(page) ( GinPageGetOpaque(page)->rightlink == InvalidBlockNumber)
 
@@ -134,7 +136,29 @@ typedef struct GinMetaPageData
  * its deletion is over.
  */
 #define GinPageGetDeleteXid(page) ( ((PageHeader) (page))->pd_prune_xid )
-#define GinPageSetDeleteXid(page, xid) ( ((PageHeader) (page))->pd_prune_xid = xid)
+
+typedef struct GINDeletedPageContents
+{
+	FullTransactionId deleteXid;
+} GINDeletedPageContents;
+
+static inline FullTransactionId
+GinPageGetDeleteFullXid(Page page)
+{
+	Assert(GinPageIsDeleted(page));
+	Assert(GinPageHasFullDeleteXid(page));
+
+	return ((GINDeletedPageContents *) PageGetContents(page))->deleteXid;
+}
+
+static inline void
+GinPageSetDeleteFullXid(Page page, FullTransactionId deleteXid)
+{
+	GinPageGetOpaque(page)->flags |= GIN_DELETED | GIN_DELETED_FULL_XID;
+	((PageHeader) page)->pd_lower =
+		MAXALIGN(SizeOfPageHeaderData) + sizeof(GINDeletedPageContents);
+	((GINDeletedPageContents *) PageGetContents(page))->deleteXid = deleteXid;
+}
 extern bool GinPageIsRecyclable(Page page);
 
 /*
diff --git a/src/include/access/ginxlog.h b/src/include/access/ginxlog.h
index 74fdf9b3d7e..65bd457d96f 100644
--- a/src/include/access/ginxlog.h
+++ b/src/include/access/ginxlog.h
@@ -156,7 +156,7 @@ typedef struct ginxlogDeletePage
 {
 	OffsetNumber parentOffset;
 	BlockNumber rightLink;
-	TransactionId deleteXid;	/* last Xid which could see this page in scan */
+	FullTransactionId deleteXid; /* last XID that could see the page */
 } ginxlogDeletePage;
 
 #define XLOG_GIN_UPDATE_META_PAGE 0x60
-- 
That's all, folks. May the source be with you.

