From ee4890be2048797abcaba28c0f36fb055dd749e0 Mon Sep 17 00:00:00 2001
From: Salma Elsayed <salmasayed182003@gmail.com>
Date: Tue, 27 Oct 2026 20:15:59 +0300
Subject: [PATCH v4 1/3] nbtree: merge nearly empty leaf pages

Move all tuples of a nearly empty leaf page into its right sibling, and
leave the left page behind as a tombstone that holds the boundary key.
Scans that were already in progress use the boundary key so they do
not lose or repeat tuples.  VACUUM removes the tombstone later through
normal page deletion.

The new WAL records go in a new resource manager, Btree2, since btree
has no free info codes.  XLOG_PAGE_MAGIC is bumped.  Nothing triggers
merges automatically yet.  See the nbtree README for the design.
---
 src/backend/access/nbtree/Makefile      |   1 +
 src/backend/access/nbtree/README        | 268 ++++++++++
 src/backend/access/nbtree/meson.build   |   1 +
 src/backend/access/nbtree/nbtdedup.c    |   6 +-
 src/backend/access/nbtree/nbtinsert.c   |  40 +-
 src/backend/access/nbtree/nbtmerge.c    | 635 ++++++++++++++++++++++++
 src/backend/access/nbtree/nbtpage.c     | 105 +++-
 src/backend/access/nbtree/nbtreadpage.c | 129 ++++-
 src/backend/access/nbtree/nbtree.c      | 399 ++++++++++++++-
 src/backend/access/nbtree/nbtsearch.c   | 387 ++++++++++++++-
 src/backend/access/nbtree/nbtutils.c    |  10 +-
 src/backend/access/nbtree/nbtxlog.c     | 245 ++++++++-
 src/backend/access/rmgrdesc/nbtdesc.c   |  59 +++
 src/bin/pg_waldump/t/001_basic.pl       |   3 +-
 src/include/access/nbtree.h             | 145 ++++++
 src/include/access/nbtxlog.h            |  56 ++-
 src/include/access/rmgrlist.h           |   1 +
 src/include/access/xlog_internal.h      |   2 +-
 src/tools/pgindent/typedefs.list        |   7 +
 19 files changed, 2470 insertions(+), 29 deletions(-)
 create mode 100644 src/backend/access/nbtree/nbtmerge.c

diff --git a/src/backend/access/nbtree/Makefile b/src/backend/access/nbtree/Makefile
index 0daf640af96..5593bb9c05f 100644
--- a/src/backend/access/nbtree/Makefile
+++ b/src/backend/access/nbtree/Makefile
@@ -16,6 +16,7 @@ OBJS = \
 	nbtcompare.o \
 	nbtdedup.o \
 	nbtinsert.o \
+	nbtmerge.o \
 	nbtpage.o \
 	nbtpreprocesskeys.o \
 	nbtreadpage.o \
diff --git a/src/backend/access/nbtree/README b/src/backend/access/nbtree/README
index f3867547691..cff38879adb 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -440,6 +440,274 @@ whenever it is subsequently taken from the FSM for reuse.  The deleted
 page's contents will be overwritten by the split operation (it will become
 the new right sibling page).
 
+Merging leaf pages
+------------------
+
+Page deletion only removes leaf pages that are completely empty.  After
+many deletions an index can also have many leaf pages that are nearly
+empty, which page deletion cannot help with.  Merging moves all the tuples
+of such a page into its right sibling.  The left page is then empty, and
+VACUUM removes it later.  The code is in nbtmerge.c.
+
+In this section L is the left page and R its right sibling.  After the
+merge, R holds the tuples of both pages and is marked BTP_MERGED; we call it
+a merged page.  L is marked BTP_MERGED_AWAY; we call it a tombstone.  R
+stores L's block number in pd_prune_xid, which nbtree does not otherwise use
+on leaf pages.  The tombstone stores the boundary key and safemergexid.
+The boundary key is L's last key together with its largest heap TID, so it
+tells L's tuples apart from R's even when both pages hold the same key.
+safemergexid is the next transaction ID at the time of the merge; it plays
+the same role as safexid does for deleted pages.
+
+The tuples move right, not left, for the same reason a page split moves
+tuples right.  A search that read the parent before the merge may still
+arrive at L.  It finds a tombstone and moves right to R, where L's keys now
+are.  This is the Lehman and Yao way to recover from a concurrent change,
+and it means searches and inserts never have to move left to find a key.
+Scans are different: some of them do read L after the merge, to get the
+boundary key.  This is explained in the sections about scans below.
+
+L and R must have the same parent.  This is the same rule as for page
+deletion (see above): the key space of a page can only move into its right
+sibling when both have the same parent, or the separator keys in the
+grandparent would have to change too.  In the parent, L's downlink is made
+to point to R, and R's downlink is removed.  The key space of both pages now
+leads to R.  R's high key does not change.
+
+Both pages must be under a fill threshold, and the tuples of both must fit
+on R within a target fillfactor.  A page that is deleted, half-dead,
+merged, a tombstone, or not finished splitting is never merged.  A merged
+page is not merged again until VACUUM has cleaned up its merge group (see
+below).  The leftmost leaf page is never merged away: if it became a
+tombstone and then half-dead, a backward scan would reach a half-dead
+leftmost page, which it does not expect.
+
+Merges are found by walking the leaf level from left to right, holding
+share locks on two neighbors at a time.  When a pair qualifies, the locks
+are released and a new descent finds the parent and checks that L's
+downlink comes right before R's.  The merge itself then locks L, R and the
+parent, in that order: left to right, then up, as for a page split, so it
+cannot deadlock with splits or page deletion.  With all three pages locked,
+everything is checked again, since anything may have changed while no lock
+was held.
+
+The lock on L is a cleanup lock.  Index-only scans, and scans that use a
+non-MVCC snapshot, keep their pin on a leaf page while they return its
+tuples (see "Making concurrent TID recycling safe" below).  Moving the
+tuples off a page that such a scan has pinned would break that rule.  The
+cleanup lock is only tried, never waited for: if L is pinned, the merge
+skips the pair, and a later merge can try again.
+
+The merge changes the three pages in one critical section with one WAL
+record.  L's tuples and then R's are written to R; R keeps its high key and
+is marked merged.  The parent is changed as described above.  L is
+reinitialized as a tombstone.  No sibling link changes: L's left sibling
+still points to L, and L still points to R.  Predicate locks on L are moved
+to R.
+
+A merge group is a tombstone together with the merged pages that point to
+it.  At first that is just R, but R can split later, and both halves keep
+the merged flag and the tombstone's block number.  The merged pages of a
+group are always next to each other, right of the tombstone.  Splits,
+deduplication and page deletion all rebuild the page, so their WAL records
+carry the tombstone's block number, and redo puts it back.  VACUUM can
+delete merged pages that become empty, and can even delete all of them
+while the tombstone stays; scans handle that case too.
+
+Deduplication can later put TIDs from both sides of the boundary into one
+posting list, for example when L's last key equals R's first key, or when
+new tuples with that key are inserted.  This is why the boundary key has a
+heap TID: TIDs at or below the boundary TID came from L, and the others
+did not.
+
+Merging and forward scans
+-------------------------
+
+A merge can happen while a scan is between two pages.  The scan must not
+return a tuple twice, and must not miss one.
+
+A forward scan that reaches L after the merge finds a tombstone.  It
+remembers the tombstone's block number and moves right.  When it reaches a
+merged page that points to that tombstone, it knows it has not returned
+any of L's tuples, so it returns all tuples of the merged page.
+
+A forward scan that read L before the merge has already returned L's
+tuples.  When it steps to R, it finds a merged page that points to a
+tombstone it never passed.  The scan must skip L's tuples on R, so it needs
+the boundary key.  It releases its lock on R, keeping the pin, locks the
+tombstone by its block number, copies the boundary key, releases the
+tombstone and locks R again.  The lock on R is released first because pages
+are always locked left to right.  This scan read L before the merge, so its
+snapshot is older than safemergexid, and VACUUM cannot clean up the group
+while it runs.  The tombstone is still a tombstone when the scan locks it.
+R may change while it is unlocked, so the scan reads R's page again after
+locking it.
+
+On the merged page, _bt_readpage saves all matching tuples in so->currPos,
+as for any page, but sets so->currPos.itemIndex to the first tuple after
+the boundary key.  For a posting list with the boundary key, that is the
+first TID above the boundary TID.  The tuples before it stay in
+so->currPos (see "Merging and scans that change direction" below).  If no
+tuple on the page is after the boundary, there is no place to start, so
+_bt_readpage reports that the page has no matches, and the scan moves to
+the next page with the filter still on.  Once a starting tuple is found,
+the filter is turned off, and the rest of the group is read as usual.
+
+A scan that descends straight to a merged page has not seen L at all.  It
+treats the group as already passed, the same as if it had stepped over the
+tombstone, and never loads the boundary key.  A new descent also clears
+any merge state the scan had before, since a scan can end inside a group
+and then start again somewhere else.
+
+Merging and backward scans
+--------------------------
+
+A backward scan that reaches a merge group from the right after the merge
+reads every merged page of the group as usual, then steps left onto the
+tombstone.  It has already returned L's tuples from the merged pages, so it
+skips the tombstone and moves on to the tombstone's left sibling.
+
+A backward scan that read R before the merge has already returned R's
+tuples.  When it steps left to L, it finds a tombstone, and L's tuples are
+now in the merged pages to its right.  This is the case that needs the
+most work.  The scan copies the boundary key from the tombstone.  Since R
+may have split since the merge, L's tuples may be on any merged page of the
+group, so the scan walks right from the tombstone to the last merged page
+of the group, coupling locks as it goes.  It then reads the group from
+right to left as a backward scan normally does, using the page right of
+the group as the "original page" of the move-left algorithm.  On each
+merged page, _bt_readpage again saves all matching tuples, and sets
+so->currPos.itemIndex to the last tuple at or below the boundary key; for
+a posting list with the boundary key, the last TID at or below the
+boundary TID.  If the page has no such tuple, there is no place to start,
+and the scan moves left to the next page.  The filter stays on until the
+scan reaches the tombstone again.  It then skips the tombstone and moves on
+to the tombstone's left sibling.
+
+If VACUUM has deleted every merged page of the group, the tombstone's right
+sibling is no longer a merged page of the group.  Then there is nothing to
+recover, and the scan just steps past the tombstone.
+
+Merging and scans that change direction
+---------------------------------------
+
+A cursor can change direction in the middle of a page.  For a normal page
+this just works: _bt_readpage saved all matching tuples of the page in
+so->currPos, and the scan returns them in the new direction, then reads the
+page on the other side.
+
+A merged page read with the boundary filter works the same way, because
+the filter does not throw away the tuples on the other side of the
+boundary: they are saved in so->currPos, and only the starting item is
+moved to the boundary (see above).  If the cursor turns,
+the scan returns the saved tuples in the new direction.  For a forward scan
+those are L's tuples, which is correct: they come right before the tuples
+it just returned.  If they had not been saved, the scan would have to go
+back to L to find them, and L is now an empty tombstone.  The same holds for
+a backward scan and R's tuples.
+
+The merge state of the scan is kept in BTScanPosData, with the rest of the
+page's state, so that it always matches the page in so->currPos.  When the
+scan leaves a group, it clears the state but keeps the boundary key, which
+lives in BTScanOpaqueData, because a turn or a restore can bring the scan
+back into the group.
+
+Merging, parallel scans, and mark and restore
+---------------------------------------------
+
+In a parallel scan, one process may read L before the merge while another
+reaches R after it, so a process cannot tell from its own state whether
+L's tuples were already returned.  The shared scan state therefore holds
+one more block number: a tombstone whose L tuples are already taken care
+of.  A process sets it when it steps over a tombstone in a forward scan,
+when it reads past the boundary, when it reads a merged page in a backward
+scan, and when it descends straight into a merge group.  A process that
+reaches a merged page in a forward scan, or a tombstone in a backward scan,
+checks this field instead of its own state.
+
+A forward scan needs nothing more.  Its processes take the pages of a
+group from the shared scan one at a time, as usual, and each one can apply
+the boundary filter to its own page, since the filter only depends on the
+boundary key.  The forward scan that steps over a tombstone sets the field
+before it hands out the next page, so a process that gets a merged page of
+that group always sees it.  Each page is handed out only once, so L is read
+either before the merge or as a tombstone, never both.
+
+A backward scan that must recover L's tuples is different, since it has to
+go back to pages right of the tombstone, which the shared scan has already
+handed out.  So it reads the pages of the group itself, without taking
+them from the shared scan.  It seized the shared scan to read the
+tombstone, and keeps it seized while it reads the group; it releases it
+only when it comes back to the tombstone, with the tombstone's left sibling
+as the next page.  The other processes wait until then.
+
+Mark and restore copies so->currPos to so->markPos and back, and the merge
+state is part of it.  The boundary key is not, so when a mark is taken
+while the boundary filter is on, a copy of the boundary key is saved with
+the mark.  The scan may load the boundary key of another group after the
+mark.  On restore, if the loaded key is missing or comes from another
+tombstone, it is rebuilt from the saved copy.  A merge join that restores
+its mark across two merge groups depends on this.
+
+Cleaning up merge groups
+------------------------
+
+A scan that started before a merge may still need the tombstone and its
+boundary key.  So VACUUM only cleans up a merge group once safemergexid is
+older than every running transaction.  This is the same drain technique
+used before a deleted page is recycled (see "Placing deleted pages in the
+FSM" above).
+
+VACUUM cleans up a group when its linear scan reaches the tombstone.  It
+releases its lock on the tombstone, keeping the pin, and walks right with
+lock coupling to find the last merged page of the group.  Then it walks
+back from there to the tombstone, right to left, and clears the merged flag
+on each page, with one WAL record per page.  A page that points to a
+different tombstone should never be found here; if it is, VACUUM stops and
+leaves the group for a later VACUUM.  Last, VACUUM locks the tombstone
+again, checks that it has not changed, and turns it into a half-dead page
+with a dummy high key.  The tombstone has no downlink in the parent
+anymore, so this is exactly the state after the first stage of page
+deletion.  Normal page deletion then unlinks the page from its siblings and
+marks it deleted, and it is recycled like any other deleted page.
+
+The order matters when VACUUM stops part way, for example after a crash
+between two of these records.  The next VACUUM walks right from the
+tombstone and stops at the first page that is not in the group; if that is
+the tombstone's right sibling, it takes the group for empty and goes
+straight to the last step.  Clearing from the right keeps the pages that
+are still merged together, right after the tombstone, where the next
+VACUUM finds them.  Had VACUUM cleared from the left, the page next to the
+tombstone would be cleared first, and the next VACUUM would finish the
+tombstone while merged pages further right still point to it.
+
+VACUUM only does this when it scans the index, which it does when there
+are dead tuples to remove.  Tombstones can therefore stay in the index for
+a while.
+
+A merge can also run while VACUUM scans the index, and move tuples from a
+page VACUUM has not reached into a page it already passed.  This is the
+same problem a page split causes, and it is solved the same way: the merge
+stamps both pages with the current VACUUM cycle ID, and when VACUUM finds a
+tombstone with its cycle ID whose right sibling it already passed, it goes
+back and reads that page again.
+
+Merging and WAL
+---------------
+
+The merge records use their own resource manager, Btree2, because the btree
+resource manager already uses 15 of the 16 record types it can have.
+There are three records: MERGE, CLEAR_MERGED_FLAG and MARK_MA_AS_HD.
+
+MERGE registers the tombstone, with the boundary key as its data, the
+merged page as a full page image, and the parent.  Its redo takes a cleanup
+lock on L, like the merge itself: on a hot standby, an index-only scan may
+have L pinned too.
+
+CLEAR_MERGED_FLAG and MARK_MA_AS_HD carry safemergexid.  Replaying them
+cancels standby queries whose snapshot is older than safemergexid, since
+such a query could still need the tombstone, as on the primary.
+
 Making concurrent TID recycling safe
 ------------------------------------
 
diff --git a/src/backend/access/nbtree/meson.build b/src/backend/access/nbtree/meson.build
index 812f067e710..79d225814d0 100644
--- a/src/backend/access/nbtree/meson.build
+++ b/src/backend/access/nbtree/meson.build
@@ -4,6 +4,7 @@ backend_sources += files(
   'nbtcompare.c',
   'nbtdedup.c',
   'nbtinsert.c',
+  'nbtmerge.c',
   'nbtpage.c',
   'nbtpreprocesskeys.c',
   'nbtreadpage.c',
diff --git a/src/backend/access/nbtree/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c
index af7affdf409..aeb7d67e684 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -119,6 +119,8 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
 	 */
 	newpage = PageGetTempPageCopySpecial(page);
 	PageSetLSN(newpage, PageGetLSN(page));
+	if (P_ISMERGED(opaque))
+		BTMergedPageSetMABlkno(newpage, BTMergedPageGetMABlkno(page));
 
 	/* Copy high key, if any */
 	if (!P_RIGHTMOST(opaque))
@@ -246,10 +248,12 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
 	/* XLOG stuff */
 	if (RelationNeedsWAL(rel))
 	{
-		xl_btree_dedup xlrec_dedup;
+		xl_btree_dedup xlrec_dedup = {0};
 
 		xlrec_dedup.nintervals = state->nintervals;
 
+		xlrec_dedup.merged_ma_blkno = P_ISMERGED(opaque) ? BTMergedPageGetMABlkno(page) : InvalidBlockNumber;
+
 		XLogBeginInsert();
 		XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
 		XLogRegisterData(&xlrec_dedup, SizeOfBtreeDedup);
diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c
index d5d964301c1..39f170cbc58 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -61,7 +61,6 @@ static Buffer _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key,
 						IndexTuple nposting, uint16 postingoff);
 static void _bt_insert_parent(Relation rel, Relation heaprel, Buffer buf,
 							  Buffer rbuf, BTStack stack, bool isroot, bool isonly);
-static void _bt_freestack(BTStack stack);
 static Buffer _bt_newlevel(Relation rel, Relation heaprel, Buffer lbuf, Buffer rbuf);
 static inline bool _bt_pgaddtup(Page page, Size itemsize, const IndexTupleData *itup,
 								OffsetNumber itup_off, bool newfirstdataitem);
@@ -748,7 +747,7 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel,
 				nbuf = _bt_relandgetbuf(rel, nbuf, nblkno, BT_READ);
 				page = BufferGetPage(nbuf);
 				opaque = BTPageGetOpaque(page);
-				if (!P_IGNORE(opaque))
+				if (!P_IGNORE(opaque) && !P_ISMERGEDAWAY(opaque))
 					break;
 				if (P_RIGHTMOST(opaque))
 					elog(ERROR, "fell off the end of index \"%s\"",
@@ -1069,7 +1068,7 @@ _bt_stepright(Relation rel, Relation heaprel, BTInsertState insertstate,
 			continue;
 		}
 
-		if (!P_IGNORE(opaque))
+		if (!P_IGNORE(opaque) && !P_ISMERGEDAWAY(opaque))
 			break;
 		if (P_RIGHTMOST(opaque))
 			elog(ERROR, "fell off the end of index \"%s\"",
@@ -1583,6 +1582,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
 	lopaque->btpo_level = oopaque->btpo_level;
 	/* handle btpo_cycleid after rightpage buffer acquired */
 
+	/*
+	 * If the original page was BTP_MERGED, the left-half temp page inherits
+	 * BTP_MERGED via the btpo_flags copy above.  However, the MA block number
+	 * stored in pd_prune_xid (see BTMergedPageSetMABlkno) lives in
+	 * PageHeaderData, not BTPageOpaqueData, so it is NOT carried over by the
+	 * btpo_flags assignment.  leftpage was freshly initialized by
+	 * _bt_pageinit above, leaving pd_prune_xid as InvalidTransactionId (0).
+	 * When leftpage is later copied back into origpage (memcpy at
+	 * START_CRIT_SECTION time), the original MA block number would be
+	 * silently lost, causing amcheck to report "merged page N has invalid MA
+	 * block number 0".  Propagate it now.
+	 */
+	if (P_ISMERGED(oopaque))
+		BTMergedPageSetMABlkno(leftpage, BTMergedPageGetMABlkno(origpage));
+
 	/*
 	 * Copy the original page's LSN into leftpage, which will become the
 	 * updated version of the page.  We need this because XLogInsert will
@@ -1771,6 +1785,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
 	ropaque->btpo_level = oopaque->btpo_level;
 	ropaque->btpo_cycleid = lopaque->btpo_cycleid;
 
+	/*
+	 * If the original page was BTP_MERGED, the new right-half page inherits
+	 * BTP_MERGED via the btpo_flags copy above.  However, the MA_blkno we
+	 * stored in pd_prune_xid (see BTMergedPageSetMABlkno) lives in
+	 * PageHeaderData, not BTPageOpaqueData, so it is NOT copied by the
+	 * btpo_flags assignment.  The right page was freshly allocated by
+	 * _bt_allocbuf, which calls _bt_pageinit -> PageInit, leaving
+	 * pd_prune_xid as InvalidTransactionId (0).  Propagate the MA_blkno now
+	 * so that backward scans can verify the correct tombstone for both halves
+	 * of the split merge group.
+	 */
+	if (P_ISMERGED(oopaque))
+		BTMergedPageSetMABlkno(rightpage, BTMergedPageGetMABlkno(origpage));
+
 	/*
 	 * Add new high key to rightpage where necessary.
 	 *
@@ -1994,9 +2022,11 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
 	/* XLOG stuff */
 	if (RelationNeedsWAL(rel))
 	{
-		xl_btree_split xlrec;
+		xl_btree_split xlrec = {0};
 		uint8		xlinfo;
 
+		xlrec.merged_ma_blkno = P_ISMERGED(oopaque) ? BTMergedPageGetMABlkno(origpage) : InvalidBlockNumber;
+
 		xlrec.level = ropaque->btpo_level;
 		/* See comments below on newitem, orignewitem, and posting lists */
 		xlrec.firstrightoff = firstrightoff;
@@ -2455,7 +2485,7 @@ _bt_getstackbuf(Relation rel, Relation heaprel, BTStack stack, BlockNumber child
 /*
  * _bt_freestack() -- free a retracement stack made by _bt_search_insert.
  */
-static void
+void
 _bt_freestack(BTStack stack)
 {
 	BTStack		ostack;
diff --git a/src/backend/access/nbtree/nbtmerge.c b/src/backend/access/nbtree/nbtmerge.c
new file mode 100644
index 00000000000..a8cb6cba622
--- /dev/null
+++ b/src/backend/access/nbtree/nbtmerge.c
@@ -0,0 +1,635 @@
+/*-------------------------------------------------------------------------
+ *
+ * nbtmerge.c
+ *	  Merge two adjacent underutilized leaf pages into one to reduce bloat.
+ *
+ * The merge scans leaf pages left-to-right and, when a consecutive pair both
+ * fall below a minimum fill threshold and their combined data fits within the
+ * target fill factor, copies all tuples from the left page (L) into the right
+ * page (R), redirects L's parent downlink to R, removes R's now-redundant
+ * downlink from the parent, marks L as BTP_MERGED_AWAY (tombstone), and marks
+ * R as BTP_MERGED.  VACUUM is responsible for later reclaiming tombstone pages.
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/nbtree/nbtmerge.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/nbtree.h"
+#include "access/tableam.h"
+#include "access/nbtxlog.h"
+#include "access/xloginsert.h"
+#include "common/int.h"
+#include "storage/bufmgr.h"
+#include "storage/lmgr.h"
+#include "storage/predicate.h"
+#include "miscadmin.h"
+
+typedef struct BTMergeState
+{
+
+	Relation	rel;
+	float8		min_threshold;	/* minimum fill fraction to qualify for merge */
+	float8		fillfactor;		/* maximum combined fill fraction after merge */
+	BlockNumber left_blkno;
+	BlockNumber right_blkno;
+	BTStack		stack;
+} BTMergeState;
+
+
+static int32 _bt_mergescan(Relation rel, float8 min_threshold, float8 fillfactor, int pages_limit);
+static bool _bt_mergepage(BTMergeState mstate);
+static BTScanInsert _bt_merge_mkscankey(Relation rel, Page page, BTPageOpaque opaque, IndexTuple *keytuple);
+static bool _bt_pages_mergeable(Page leftpage, Page rightpage, float8 min_threshold, float8 fillfactor);
+static IndexTuple _bt_build_boundary_pivot(Relation rel, IndexTuple itup);
+
+/*
+ * _bt_merge_index() -- Entry point: merge underutilized leaf pages.
+ *
+ * min_pct is the per-page threshold (0..100); pages below this are candidates.
+ * dest_pct is the target combined fill (0..100); the merged page must fit.
+ * num_pages caps how many leaf pairs are examined in one call.
+ *
+ * Returns the number of merges actually performed.
+ */
+int32
+_bt_merge_index(Relation rel, float8 min_pct, float8 dest_pct, int32 num_pages)
+{
+	bool		heapkeyspace;
+	bool		allequalimage;
+
+	_bt_metaversion(rel, &heapkeyspace, &allequalimage);
+	if (!heapkeyspace)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("cannot merge pages of index \"%s\" because it is a version 3 or older btree",
+						RelationGetRelationName(rel)),
+				 errhint("Use REINDEX to upgrade the index first.")));
+
+	return _bt_mergescan(rel, min_pct, dest_pct, num_pages);
+}
+
+
+/*
+ * _bt_merge_mkscankey() -- Build a BTScanInsert key from the first data tuple
+ * on a leaf page.  Returns NULL if the page carries no data tuples.  Caller
+ * must pfree the returned key.
+ */
+static BTScanInsert
+_bt_merge_mkscankey(Relation rel, Page page, BTPageOpaque opaque, IndexTuple *keytuple)
+{
+	OffsetNumber first_off = P_FIRSTDATAKEY(opaque);
+
+	*keytuple = NULL;
+	if (first_off > PageGetMaxOffsetNumber(page))
+		return NULL;
+
+	*keytuple = CopyIndexTuple((IndexTuple) PageGetItem(page, PageGetItemId(page, first_off)));
+
+	return _bt_mkscankey(rel, *keytuple);
+}
+
+/*
+ * _bt_pages_mergeable() -- Return true when the pair qualifies for merging.
+ *
+ * Both pages must individually be below min_threshold and their combined used
+ * space must fit within fillfactor.  Thresholds are fractions (0.0 - 1.0).
+ */
+static bool
+_bt_pages_mergeable(Page leftpage, Page rightpage,
+					float8 min_threshold, float8 fillfactor)
+{
+	BTPageOpaque leftopaque = BTPageGetOpaque(leftpage);
+	Size		left_used = BLCKSZ - PageGetFreeSpace(leftpage);
+	Size		right_used = BLCKSZ - PageGetFreeSpace(rightpage);
+	Size		bytes_needed = 0;
+	OffsetNumber maxoff_left = PageGetMaxOffsetNumber(leftpage);
+	OffsetNumber first_left = P_FIRSTDATAKEY(leftopaque);
+
+	/* Check individual threshold qualifications first */
+	if ((float8) left_used / BLCKSZ > min_threshold ||
+		(float8) right_used / BLCKSZ > min_threshold)
+		return false;
+
+	/* Compute exact bytes needed for all data tuples transferred from L */
+	for (OffsetNumber off = first_left; off <= maxoff_left; off++)
+	{
+		ItemId		itemid = PageGetItemId(leftpage, off);
+		IndexTuple	itup = (IndexTuple) PageGetItem(leftpage, itemid);
+
+		bytes_needed += MAXALIGN(IndexTupleSize(itup)) + sizeof(ItemIdData);
+	}
+
+	/* Ensure R has enough physical free space to hold all transferred tuples */
+	if (PageGetFreeSpace(rightpage) < bytes_needed)
+		return false;
+
+	/* Ensure total resulting size fits within the specified fillfactor */
+	if ((float8) (right_used + bytes_needed) / BLCKSZ > fillfactor)
+		return false;
+
+	return true;
+}
+
+
+/*
+ * _bt_mergescan() -- Walk leaf pages left-to-right looking for merge
+ * candidates.
+ *
+ * Scans leaf pages starting from the second leftmost.  For each
+ * candidate pair (L, R), verifies they share a parent, then calls
+ * _bt_mergepage() to perform the actual merge under exclusive locks.
+ *
+ * Stops after pages_limit leaf pages have been examined or the rightmost leaf
+ * is reached.  Returns the number of merges performed.
+ */
+static int32
+_bt_mergescan(Relation rel, float8 min_threshold, float8 fillfactor, int pages_limit)
+{
+	Buffer		leftbuf,
+				rightbuf;
+	Page		leftpage,
+				rightpage,
+				temp_page;
+	BTPageOpaque leftopaque,
+				rightopaque,
+				temp_opaque;
+	BlockNumber left_blkno,
+				right_blkno,
+				current_blkno,
+				r_right_blkno;
+	int32		merges_performed = 0;
+	int			num_pages = 0;
+	BTScanInsert scankey;
+	BTMergeState mstate;
+	BTStack		stack;
+	IndexTuple	keytuple;
+
+	mstate.rel = rel;
+	mstate.min_threshold = min_threshold / 100.0;
+	mstate.fillfactor = fillfactor / 100.0;
+
+	/* Start from the second leftmost leaf page. */
+	{
+		Buffer		endpoint_buf = _bt_get_endpoint(rel, 0, false);
+
+		if (BufferIsInvalid(endpoint_buf))
+			return 0;
+
+		current_blkno = BufferGetBlockNumber(endpoint_buf);
+		temp_page = BufferGetPage(endpoint_buf);
+		temp_opaque = BTPageGetOpaque(temp_page);
+		current_blkno = temp_opaque->btpo_next;
+		UnlockReleaseBuffer(endpoint_buf);
+	}
+
+	for (;;)
+	{
+		CHECK_FOR_INTERRUPTS();
+
+		if (num_pages >= pages_limit || current_blkno == P_NONE)
+			return merges_performed;
+
+		/* Pin and share-lock the left candidate. */
+		left_blkno = current_blkno;
+		leftbuf = ReadBuffer(rel, left_blkno);
+		LockBuffer(leftbuf, BUFFER_LOCK_SHARE);
+		leftpage = BufferGetPage(leftbuf);
+		leftopaque = BTPageGetOpaque(leftpage);
+
+		if (P_RIGHTMOST(leftopaque))
+		{
+			UnlockReleaseBuffer(leftbuf);
+			return merges_performed;
+		}
+
+		if (P_ISDELETED(leftopaque) || P_ISHALFDEAD(leftopaque)
+			|| P_ISMERGED(leftopaque) || P_ISMERGEDAWAY(leftopaque))
+		{
+			current_blkno = leftopaque->btpo_next;
+			UnlockReleaseBuffer(leftbuf);
+			continue;
+		}
+
+		Assert(P_ISLEAF(leftopaque));
+
+		/* Pin and share-lock the right candidate. */
+		right_blkno = leftopaque->btpo_next;
+		rightbuf = ReadBuffer(rel, right_blkno);
+		LockBuffer(rightbuf, BUFFER_LOCK_SHARE);
+		rightpage = BufferGetPage(rightbuf);
+		rightopaque = BTPageGetOpaque(rightpage);
+
+		if (P_ISDELETED(rightopaque) || P_ISHALFDEAD(rightopaque)
+			|| P_ISMERGED(rightopaque) || P_ISMERGEDAWAY(rightopaque))
+		{
+			current_blkno = rightopaque->btpo_next;
+			UnlockReleaseBuffer(rightbuf);
+			UnlockReleaseBuffer(leftbuf);
+			continue;
+		}
+
+		Assert(P_ISLEAF(rightopaque));
+
+		/* Save R's right sibling while we still hold the share lock on R. */
+		r_right_blkno = rightopaque->btpo_next;
+
+		/* L is examined; slide the window to R as the default next-left. */
+		num_pages++;
+		current_blkno = right_blkno;
+
+		scankey = _bt_merge_mkscankey(rel, leftpage, leftopaque, &keytuple);
+
+		if (_bt_pages_mergeable(leftpage, rightpage,
+								mstate.min_threshold, mstate.fillfactor) &&
+			scankey != NULL)
+		{
+			/*
+			 * Advance to R's original right sibling (r_right_blkno), not to R
+			 * itself, leaving R untouched even if the merge below fails. That
+			 * way, if the failure was L being pinned, L can retry against the
+			 * same R next time.  A false return doesn't always mean L was
+			 * pinned; it can also mean one of the other preconditions
+			 * _bt_mergepage() rechecks failed.  Those should be rare here,
+			 * though, since we already checked the same conditions above
+			 * under share lock just before calling it.
+			 *
+			 * Drop share locks before calling _bt_pages_share_parent().  It
+			 * descends via _bt_search and will try to lock these same blocks
+			 * itself.
+			 */
+			num_pages++;
+			current_blkno = r_right_blkno;
+			UnlockReleaseBuffer(rightbuf);
+			UnlockReleaseBuffer(leftbuf);
+
+			if (_bt_pages_share_parent(rel, left_blkno, right_blkno,
+									   scankey, &stack))
+			{
+				mstate.left_blkno = left_blkno;
+				mstate.right_blkno = right_blkno;
+				mstate.stack = stack;
+
+				if (_bt_mergepage(mstate))
+					merges_performed++;
+
+				_bt_freestack(stack);
+			}
+
+			pfree(scankey);
+			pfree(keytuple);
+			continue;
+		}
+
+		/* Pages don't qualify; current_blkno already points at R. */
+		UnlockReleaseBuffer(rightbuf);
+		UnlockReleaseBuffer(leftbuf);
+		if (scankey)
+		{
+			pfree(scankey);
+			pfree(keytuple);
+		}
+	}
+}
+
+
+/*
+ * _bt_mergepage() -- Perform one leaf-page merge.
+ *
+ * Re-acquires exclusive locks on L, R, and their parent, re-verifies all
+ * preconditions under those exclusive locks, then:
+ *
+ *   1. Saves R's high key and all data tuples to palloc'd memory.
+ *   2. Re-initializes R's page, restoring its opaque header and high key.
+ *   3. Copies all data tuples from L into R, followed by R's original tuples.
+ *   4. Redirects L's downlink in the parent to point to R, then deletes R's
+ *      now-redundant downlink entry from the parent.
+ *   5. Marks R as BTP_MERGED, recording L's block number in R's pd_prune_xid
+ *      field so backward scans can identify the merge group tombstone.
+ *   6. Marks L as BTP_MERGED_AWAY, recording the boundary between L and R
+ *      (a pivot tuple) and a safemergexid, so VACUUM can tell when the
+ *      tombstone is safe to reclaim.
+ *
+ * Returns true if the merge completed, false if any precondition failed.
+ */
+static bool
+_bt_mergepage(BTMergeState mstate)
+{
+	Relation	rel = mstate.rel;
+	BlockNumber parent_blkno;
+	Buffer		leftbuf,
+				rightbuf,
+				parentbuf = InvalidBuffer;
+	Page		leftpage,
+				rightpage,
+				parentpage;
+	BTPageOpaque leftopaque,
+				rightopaque,
+				popaque;
+	BTStack		stack = mstate.stack;
+	ItemId		itemid;
+	IndexTuple	itup,
+				left_itup,
+				boundary_pivot,
+				r_hikey = NULL;
+	Size		r_hikey_size = 0,
+				sz;
+	OffsetNumber next_off;
+	int			n_right;
+	IndexTuple *r_tuples = NULL;
+	Size	   *r_sizes = NULL;
+	BTPageOpaqueData saved_opaque;
+	FullTransactionId safemergexid;
+	bool		merged = false;
+	XLogRecPtr	recptr;
+	BTCycleId	cycleid;
+
+	parent_blkno = stack->bts_blkno;
+
+	/*
+	 * Insist on a cleanup lock for L, not just BT_WRITE.  Index-only scans
+	 * (and plain scans on a non-MVCC snapshot) keep their pin on a leaf page
+	 * the whole time they're using its cached entries, trusting that pin to
+	 * stop VACUUM from recycling anything underneath them.  Moving L's tuples
+	 * into R without checking for that pin would defeat it: the scan's pin
+	 * stays on an empty tombstone while the data it's protecting quietly
+	 * becomes R's problem, where nothing knows it needs protecting.
+	 *
+	 * Use the conditional (non-blocking) form: a contended pin just means
+	 * this pair isn't ready yet, not that bt_merge() should wait for it.
+	 */
+	leftbuf = ReadBuffer(rel, mstate.left_blkno);
+	if (!ConditionalLockBufferForCleanup(leftbuf))
+	{
+		ReleaseBuffer(leftbuf);
+		return false;
+	}
+	leftpage = BufferGetPage(leftbuf);
+	leftopaque = BTPageGetOpaque(leftpage);
+	Assert(P_ISLEAF(leftopaque));
+
+	rightbuf = ReadBuffer(rel, mstate.right_blkno);
+	LockBuffer(rightbuf, BT_WRITE);
+	rightpage = BufferGetPage(rightbuf);
+	rightopaque = BTPageGetOpaque(rightpage);
+	Assert(P_ISLEAF(rightopaque));
+
+	/* Re-verify left & right leaf pages under exclusive lock. */
+	if (P_ISDELETED(leftopaque) || P_ISHALFDEAD(leftopaque) ||
+		P_ISMERGED(leftopaque) || P_ISMERGEDAWAY(leftopaque) ||
+		P_INCOMPLETE_SPLIT(leftopaque) ||
+		leftopaque->btpo_next != mstate.right_blkno)
+		goto unlock_leaf_bufs;
+
+	if (P_ISDELETED(rightopaque) || P_ISHALFDEAD(rightopaque) ||
+		P_ISMERGED(rightopaque) || P_ISMERGEDAWAY(rightopaque) ||
+		P_INCOMPLETE_SPLIT(rightopaque))
+		goto unlock_leaf_bufs;
+
+	if (!_bt_pages_mergeable(leftpage, rightpage,
+							 mstate.min_threshold, mstate.fillfactor))
+		goto unlock_leaf_bufs;
+
+	if (PageGetMaxOffsetNumber(leftpage) < P_FIRSTDATAKEY(leftopaque) ||
+		PageGetMaxOffsetNumber(rightpage) < P_FIRSTDATAKEY(rightopaque))
+		goto unlock_leaf_bufs;
+
+	/*
+	 * Lock the parent and confirm that downlinks at bts_offset and
+	 * bts_offset+1 still point to L and R respectively.
+	 */
+	parentbuf = ReadBuffer(rel, parent_blkno);
+	LockBuffer(parentbuf, BT_WRITE);
+	parentpage = BufferGetPage(parentbuf);
+	popaque = BTPageGetOpaque(parentpage);
+
+	if (P_ISDELETED(popaque) || P_ISHALFDEAD(popaque) ||
+		popaque->btpo_level != leftopaque->btpo_level + 1)
+		goto unlock_all_bufs;
+
+	next_off = OffsetNumberNext(stack->bts_offset);
+	if (stack->bts_offset < P_FIRSTDATAKEY(popaque) ||
+		next_off > PageGetMaxOffsetNumber(parentpage))
+		goto unlock_all_bufs;
+
+	/* Verify L's downlink */
+	itemid = PageGetItemId(parentpage, stack->bts_offset);
+	if (!ItemIdIsNormal(itemid))
+		goto unlock_all_bufs;
+	left_itup = (IndexTuple) PageGetItem(parentpage, itemid);
+	if (BTreeTupleGetDownLink(left_itup) != mstate.left_blkno)
+		goto unlock_all_bufs;
+
+	/* Verify R's downlink */
+	itemid = PageGetItemId(parentpage, next_off);
+	if (!ItemIdIsNormal(itemid))
+		goto unlock_all_bufs;
+	itup = (IndexTuple) PageGetItem(parentpage, itemid);
+	if (BTreeTupleGetDownLink(itup) != mstate.right_blkno)
+		goto unlock_all_bufs;
+
+	/* Save R's high key (if not rightmost). */
+	if (!P_RIGHTMOST(rightopaque))
+	{
+		ItemId		hikey_id = PageGetItemId(rightpage, P_HIKEY);
+
+		r_hikey_size = ItemIdGetLength(hikey_id);
+		r_hikey = (IndexTuple) palloc(r_hikey_size);
+		memcpy(r_hikey, PageGetItem(rightpage, hikey_id), r_hikey_size);
+	}
+
+	/* Save all of R's data tuples into temporary memory. */
+	{
+		OffsetNumber r_start = P_FIRSTDATAKEY(rightopaque);
+		OffsetNumber r_maxoff = PageGetMaxOffsetNumber(rightpage);
+
+		n_right = (r_maxoff >= r_start) ? (r_maxoff - r_start + 1) : 0;
+		r_tuples = palloc_array(IndexTuple, n_right);
+		r_sizes = palloc_array(Size, n_right);
+
+		for (int i = 0; i < n_right; i++)
+		{
+			itemid = PageGetItemId(rightpage, r_start + i);
+			sz = ItemIdGetLength(itemid);
+			itup = (IndexTuple) PageGetItem(rightpage, itemid);
+			r_tuples[i] = (IndexTuple) palloc(sz);
+			memcpy(r_tuples[i], itup, sz);
+			r_sizes[i] = sz;
+		}
+	}
+
+	safemergexid = ReadNextFullTransactionId();
+
+	/*
+	 * Read the active VACUUM cycle ID now, while L and R are locked.  A
+	 * VACUUM that starts after this cannot get past either page until we are
+	 * done.
+	 */
+	cycleid = _bt_vacuum_cycleid(rel);
+
+	itup = (IndexTuple) PageGetItem(leftpage,
+									PageGetItemId(leftpage, PageGetMaxOffsetNumber(leftpage)));
+	boundary_pivot = _bt_build_boundary_pivot(rel, itup);
+
+	/*
+	 * Any failure across the three-buffer update (L becomes a tombstone, R
+	 * absorbs all tuples, parent loses R's downlink) would leave the index in
+	 * an inconsistent state.  Perform all three modifications as an atomic
+	 * unit inside a critical section so that any error panics the server
+	 * rather than aborting with a partially updated index.
+	 */
+	START_CRIT_SECTION();
+
+	/* Reinitialize R, preserving its opaque header. */
+	saved_opaque = *rightopaque;
+	PageInit(rightpage, BufferGetPageSize(rightbuf), sizeof(BTPageOpaqueData));
+	*BTPageGetOpaque(rightpage) = saved_opaque;
+
+	/*
+	 * Stamp R with the cycle ID, as a split does for its new pages.  If this
+	 * VACUUM has already passed R, the stamp tells it that R got new tuples.
+	 */
+	rightopaque->btpo_cycleid = cycleid;
+
+	if (r_hikey != NULL)
+	{
+		if (PageAddItem(rightpage, r_hikey, r_hikey_size,
+						P_HIKEY, false, false) == InvalidOffsetNumber)
+			elog(PANIC, "failed to restore high key to merged page");
+	}
+
+	/* Copy L's data tuples into R. */
+	for (OffsetNumber off = P_FIRSTDATAKEY(leftopaque);
+		 off <= PageGetMaxOffsetNumber(leftpage);
+		 off++)
+	{
+		itemid = PageGetItemId(leftpage, off);
+		sz = ItemIdGetLength(itemid);
+		itup = (IndexTuple) PageGetItem(leftpage, itemid);
+
+		if (PageAddItem(rightpage, itup, sz,
+						InvalidOffsetNumber, false, false) == InvalidOffsetNumber)
+			elog(PANIC, "failed to copy left tuple to merged page");
+	}
+
+	/* Append R's original data tuples after L's. */
+	for (int i = 0; i < n_right; i++)
+	{
+		if (PageAddItem(rightpage, r_tuples[i], r_sizes[i],
+						InvalidOffsetNumber, false, false) == InvalidOffsetNumber)
+			elog(PANIC, "failed to copy right tuple to merged page");
+	}
+
+	/* Redirect L's downlink to R and delete R's downlink entry. */
+	BTreeTupleSetDownLink(left_itup, mstate.right_blkno);
+	PageIndexTupleDelete(parentpage, next_off);
+
+	BTPageSetMerged(rightpage);
+	BTMergedPageSetMABlkno(rightpage, mstate.left_blkno);
+
+	saved_opaque = *leftopaque;
+	PageInit(leftpage, BufferGetPageSize(leftbuf), sizeof(BTPageOpaqueData));
+	*BTPageGetOpaque(leftpage) = saved_opaque;
+
+	/*
+	 * Stamp the tombstone too.  L can be at a higher block than R, so VACUUM
+	 * may reach L after passing R.  The restore of saved_opaque above brought
+	 * back L's old stamp, so this has to come after it.
+	 */
+	leftopaque->btpo_cycleid = cycleid;
+	BTPageSetMergedAway(leftpage, safemergexid, boundary_pivot, IndexTupleSize(boundary_pivot));
+
+	MarkBufferDirty(leftbuf);
+	MarkBufferDirty(rightbuf);
+	MarkBufferDirty(parentbuf);
+
+	if (RelationNeedsWAL(rel))
+	{
+		xl_btree_merge xlrec = {0};
+
+		xlrec.left_prev = leftopaque->btpo_prev;
+		xlrec.left_next = mstate.right_blkno;
+		xlrec.poffset = stack->bts_offset;
+		xlrec.safemergexid = safemergexid;
+
+		XLogBeginInsert();
+		XLogRegisterData(&xlrec, SizeOfBtreeMerge);
+
+		XLogRegisterBuffer(0, leftbuf, REGBUF_WILL_INIT);
+
+		XLogRegisterBufData(0, (char *) boundary_pivot, IndexTupleSize(boundary_pivot));
+
+		XLogRegisterBuffer(1, rightbuf, REGBUF_FORCE_IMAGE | REGBUF_STANDARD);
+		XLogRegisterBuffer(2, parentbuf, REGBUF_STANDARD);
+
+		recptr = XLogInsert(RM_BTREE2_ID, XLOG_BTREE2_MERGE);
+	}
+	else
+		recptr = XLogGetFakeLSN(rel);
+
+	PageSetLSN(leftpage, recptr);
+	PageSetLSN(rightpage, recptr);
+	PageSetLSN(parentpage, recptr);
+
+
+	END_CRIT_SECTION();
+
+	/* Free temporary memory. */
+	if (r_hikey != NULL)
+		pfree(r_hikey);
+	for (int i = 0; i < n_right; i++)
+		pfree(r_tuples[i]);
+	pfree(r_tuples);
+	pfree(r_sizes);
+	pfree(boundary_pivot);
+
+	PredicateLockPageCombine(rel, mstate.left_blkno, mstate.right_blkno);
+	merged = true;
+
+unlock_all_bufs:
+	UnlockReleaseBuffer(parentbuf);
+
+unlock_leaf_bufs:
+	UnlockReleaseBuffer(rightbuf);
+	UnlockReleaseBuffer(leftbuf);
+
+	return merged;
+}
+
+/*
+ * Build the boundary key stored on the tombstone from L's last tuple: its key
+ * columns plus its largest heap TID.  Scans compare tuples on the merged page
+ * with it, to tell L's tuples from R's.
+ */
+static IndexTuple
+_bt_build_boundary_pivot(Relation rel, IndexTuple itup)
+{
+	TupleDesc	itupdesc = RelationGetDescr(rel);
+	int16		nkeyatt = IndexRelationGetNumberOfKeyAttributes(rel);
+	IndexTuple	pivot;
+	IndexTuple	tidpivot;
+	Size		newsize;
+
+	pivot = index_truncate_tuple(itupdesc, itup, nkeyatt);
+
+	if (BTreeTupleIsPosting(pivot))
+	{
+		pivot->t_info &= ~INDEX_SIZE_MASK;
+		pivot->t_info |= MAXALIGN(BTreeTupleGetPostingOffset(itup));
+	}
+
+	newsize = MAXALIGN(IndexTupleSize(pivot)) + MAXALIGN(sizeof(ItemPointerData));
+	tidpivot = palloc0(newsize);
+	memcpy(tidpivot, pivot, MAXALIGN(IndexTupleSize(pivot)));
+	pfree(pivot);
+
+	tidpivot->t_info &= ~INDEX_SIZE_MASK;
+	tidpivot->t_info |= newsize;
+	BTreeTupleSetNAtts(tidpivot, nkeyatt, true);
+
+	ItemPointerCopy(BTreeTupleGetMaxHeapTID(itup), BTreeTupleGetHeapTID(tidpivot));
+
+	return tidpivot;
+}
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 83d6d367f81..4e8ad7f6056 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -1773,7 +1773,7 @@ _bt_rightsib_halfdeadflag(Relation rel, BlockNumber leafrightsib)
 	opaque = BTPageGetOpaque(page);
 
 	Assert(P_ISLEAF(opaque) && !P_ISDELETED(opaque));
-	result = P_ISHALFDEAD(opaque);
+	result = P_ISHALFDEAD(opaque) || P_ISMERGEDAWAY(opaque);
 	_bt_relbuf(rel, buf);
 
 	return result;
@@ -2114,6 +2114,7 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
 	IndexTuple	itup;
 	IndexTupleData trunctuple;
 	XLogRecPtr	recptr;
+	BlockNumber mablkno;
 
 	page = BufferGetPage(leafbuf);
 	opaque = BTPageGetOpaque(page);
@@ -2244,6 +2245,7 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
 	page = BufferGetPage(leafbuf);
 	opaque = BTPageGetOpaque(page);
 	opaque->btpo_flags |= BTP_HALF_DEAD;
+	mablkno = P_ISMERGED(opaque) ? BTMergedPageGetMABlkno(page) : InvalidBlockNumber;
 
 	Assert(PageGetMaxOffsetNumber(page) == P_HIKEY);
 	MemSet(&trunctuple, 0, sizeof(IndexTupleData));
@@ -2272,6 +2274,8 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
 		else
 			xlrec.topparent = InvalidBlockNumber;
 
+		xlrec.merged_ma_blkno = mablkno;
+
 		XLogBeginInsert();
 		XLogRegisterBuffer(0, leafbuf, REGBUF_WILL_INIT);
 		XLogRegisterBuffer(1, subtreeparent, REGBUF_STANDARD);
@@ -2350,10 +2354,13 @@ _bt_unlink_halfdead_page(Relation rel, Buffer leafbuf, BlockNumber scanblkno,
 	IndexTuple	leafhikey;
 	BlockNumber leaftopparent;
 	XLogRecPtr	recptr;
+	BlockNumber mablkno;
 
 	page = BufferGetPage(leafbuf);
 	opaque = BTPageGetOpaque(page);
 
+	mablkno = P_ISMERGED(opaque) ? BTMergedPageGetMABlkno(page) : InvalidBlockNumber;
+
 	Assert(P_ISLEAF(opaque) && !P_ISDELETED(opaque) && P_ISHALFDEAD(opaque));
 
 	/*
@@ -2576,7 +2583,7 @@ _bt_unlink_halfdead_page(Relation rel, Buffer leafbuf, BlockNumber scanblkno,
 	}
 
 	rightsib_is_rightmost = P_RIGHTMOST(opaque);
-	*rightsib_empty = (P_FIRSTDATAKEY(opaque) > PageGetMaxOffsetNumber(page));
+	*rightsib_empty = !P_ISMERGEDAWAY(opaque) && (P_FIRSTDATAKEY(opaque) > PageGetMaxOffsetNumber(page));
 
 	/*
 	 * If we are deleting the next-to-last page on the target's level, then
@@ -2718,6 +2725,8 @@ _bt_unlink_halfdead_page(Relation rel, Buffer leafbuf, BlockNumber scanblkno,
 		xlrec.leafrightsib = leafrightsib;
 		xlrec.leaftopparent = leaftopparent;
 
+		xlrec.merged_ma_blkno = mablkno;
+
 		XLogRegisterData(&xlrec, SizeOfBtreeUnlinkPage);
 
 		if (BufferIsValid(metabuf))
@@ -3136,3 +3145,95 @@ _bt_pendingfsm_add(BTVacState *vstate,
 	vstate->pendingpages[vstate->npendingpages].safexid = safexid;
 	vstate->npendingpages++;
 }
+
+/*
+ * Return true if leaf pages left_blkno and right_blkno have the same parent,
+ * with the left page's downlink right before the right page's.  scankey must
+ * find the left page.  If stack_out is not NULL, the descent stack is
+ * returned in it, for the caller to free.
+ */
+bool
+_bt_pages_share_parent(Relation rel, BlockNumber left_blkno,
+					   BlockNumber right_blkno, BTScanInsert scankey, BTStack *stack_out)
+{
+	BTStack		stack;
+	Buffer		found_buf = InvalidBuffer;
+	BlockNumber parent_blkno = InvalidBlockNumber;
+	Buffer		parentbuf;
+	Page		parentpage;
+	OffsetNumber maxoff,
+				left_off;
+	bool		found_left = false;
+	IndexTuple	itup;
+	BlockNumber child;
+
+
+	/* Descend the tree to find left's parent. Caller built scankey. */
+	stack = _bt_search(rel, NULL, scankey, &found_buf, BT_READ, true);
+
+	if (BufferIsValid(found_buf))
+		UnlockReleaseBuffer(found_buf);
+
+	if (stack == NULL)
+		return false;
+
+	parent_blkno = stack->bts_blkno;
+
+	if (parent_blkno == InvalidBlockNumber)
+	{
+		_bt_freestack(stack);
+		return false;
+	}
+
+	parentbuf = ReadBuffer(rel, parent_blkno);
+	LockBuffer(parentbuf, BUFFER_LOCK_SHARE);
+	parentpage = BufferGetPage(parentbuf);
+
+	left_off = stack->bts_offset;
+
+	maxoff = PageGetMaxOffsetNumber(parentpage);
+
+	/* The parent may have changed since the descent */
+	if (left_off < P_FIRSTDATAKEY(BTPageGetOpaque(parentpage)) || left_off > maxoff)
+	{
+		UnlockReleaseBuffer(parentbuf);
+		_bt_freestack(stack);
+		return false;
+	}
+
+	itup = (IndexTuple) PageGetItem(parentpage, PageGetItemId(parentpage, left_off));
+
+	child = ItemPointerGetBlockNumberNoCheck(&itup->t_tid);
+
+	if (child == left_blkno)
+	{
+		found_left = true;
+	}
+	/* Check that the next downlink in the parent points to the right sibling */
+	if (found_left)
+	{
+		OffsetNumber next_off = OffsetNumberNext(left_off);
+
+		if (next_off <= maxoff)
+		{
+			itup = (IndexTuple) PageGetItem(parentpage, PageGetItemId(parentpage, next_off));
+
+			child = ItemPointerGetBlockNumberNoCheck(&itup->t_tid);
+
+			if (child == right_blkno)
+			{
+				UnlockReleaseBuffer(parentbuf);
+				if (stack_out != NULL)
+					*stack_out = stack;
+				else
+					_bt_freestack(stack);
+				return true;
+			}
+		}
+	}
+
+	UnlockReleaseBuffer(parentbuf);
+	_bt_freestack(stack);
+	return false;
+
+}
diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c
index 55722fd8791..85b72b774f4 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -109,6 +109,34 @@ static void _bt_binsrch_skiparray_skey(bool cur_elem_trig, ScanDirection dir,
 static bool _bt_verify_keys_with_arraykeys(IndexScanDesc scan);
 #endif
 
+/*
+ * _bt_posting_upper_bound - Find first posting list entry whose TID > boundary.
+ */
+static int
+_bt_posting_upper_bound(IndexTuple itup, ItemPointer boundary)
+{
+	int			low = 0;
+	int			high = BTreeTupleGetNPosting(itup),
+				res;
+
+	Assert(BTreeTupleIsPosting(itup));
+	Assert(high >= 2);
+
+	while (low < high)
+	{
+		int			mid = low + (high - low) / 2;
+
+		res = ItemPointerCompare(BTreeTupleGetPostingN(itup, mid), boundary);
+
+		if (res <= 0)
+			low = mid + 1;
+		else
+			high = mid;
+	}
+
+	return low;
+}
+
 
 /*
  *	_bt_readpage() -- Load data from current index page into so->currPos
@@ -145,6 +173,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
 				ignore_killed_tuples = scan->ignore_killed_tuples;
 	int			itemIndex,
 				indnatts;
+	bool		mergeFiltered;
+	int			startIndex = -1;	/* first item to return, if mergeFiltered */
 
 	/* save the page/buffer block number, along with its sibling links */
 	page = BufferGetPage(so->currPos.buf);
@@ -156,6 +186,9 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
 	pstate.dir = so->currPos.dir = dir;
 	so->currPos.nextTupleOffset = 0;
 
+	mergeFiltered = P_ISMERGED(opaque) && so->currPos.needMergeRecovery;
+	so->currPos.mergeFiltered = mergeFiltered;
+
 	/* either moreRight or moreLeft should be set now (may be unset later) */
 	Assert(ScanDirectionIsForward(dir) ? so->currPos.moreRight :
 		   so->currPos.moreLeft);
@@ -190,7 +223,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
 		if (ScanDirectionIsForward(dir))
 			_bt_parallel_release(scan, so->currPos.nextPage,
 								 so->currPos.currPage);
-		else
+		else if (!so->currPos.needMergeRecovery)
 			_bt_parallel_release(scan, so->currPos.prevPage,
 								 so->currPos.currPage);
 	}
@@ -276,6 +309,56 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
 
 			if (passes_quals)
 			{
+				if (P_ISMERGED(opaque) && so->currPos.needMergeRecovery)
+				{
+					int			cmp;
+
+					Assert(so->mergeBoundaryKey != NULL);
+					cmp = _bt_compare(rel, so->mergeBoundaryKey, page, offnum);
+
+					/*
+					 * cmp > 0 is a tuple from L: save it for a direction
+					 * turn, but don't start there
+					 */
+
+					if (cmp == 0)
+					{
+						if (!BTreeTupleIsPosting(itup))
+						{
+							/* Crossed the boundary! Shut off recovery */
+							so->currPos.needMergeRecovery = false;
+							_bt_parallel_set_skip_ma(scan, BTMergedPageGetMABlkno(page));
+
+							startIndex = itemIndex + 1;
+						}
+						else
+						{
+							int			posStart = _bt_posting_upper_bound(itup, so->mergeBoundaryKey->scantid);
+
+							if (posStart < BTreeTupleGetNPosting(itup))
+							{
+								/* Crossed the boundary! Shut off recovery */
+								so->currPos.needMergeRecovery = false;
+								_bt_parallel_set_skip_ma(scan, BTMergedPageGetMABlkno(page));
+								startIndex = itemIndex + posStart;
+							}
+
+						}
+					}
+					else if (cmp < 0)
+					{
+
+						/*
+						 * Boundary < Tuple: this tuple is strictly after the
+						 * boundary. Belongs to R; forward scan has not seen
+						 * it yet. Keep whole tuple and shut off recovery
+						 */
+						so->currPos.needMergeRecovery = false;
+						_bt_parallel_set_skip_ma(scan, BTMergedPageGetMABlkno(page));
+						startIndex = itemIndex;
+					}
+				}
+
 				/* tuple passes all scan key conditions */
 				if (!BTreeTupleIsPosting(itup))
 				{
@@ -345,6 +428,13 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
 		so->currPos.firstItem = 0;
 		so->currPos.lastItem = itemIndex - 1;
 		so->currPos.itemIndex = 0;
+
+		if (mergeFiltered)
+		{
+			if (startIndex < 0 || startIndex > so->currPos.lastItem)
+				return false;
+			so->currPos.itemIndex = startIndex;
+		}
 	}
 	else
 	{
@@ -461,6 +551,36 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
 
 			if (passes_quals && tuple_alive)
 			{
+				int			cmp;
+
+				if (P_ISMERGED(opaque) && so->currPos.needMergeRecovery && startIndex < 0)
+				{
+					Assert(so->mergeBoundaryKey != NULL);
+
+					cmp = _bt_compare(rel, so->mergeBoundaryKey, page, offnum);
+
+					if (cmp < 0)
+					{
+						/*
+						 * Tuple from R: save it for a direction turn, but
+						 * don't start there
+						 */
+					}
+					else if (cmp == 0 && BTreeTupleIsPosting(itup))
+					{
+						/* TIDs before nfromL came from L */
+						int			nfromL = _bt_posting_upper_bound(itup, so->mergeBoundaryKey->scantid);
+
+						if (nfromL > 0)
+							startIndex = itemIndex - (BTreeTupleGetNPosting(itup) - nfromL + 1);
+					}
+					else
+					{
+						/* Last tuple from L */
+						startIndex = itemIndex - 1;
+					}
+				}
+
 				/* tuple passes all scan key conditions */
 				if (!BTreeTupleIsPosting(itup))
 				{
@@ -508,6 +628,13 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
 		so->currPos.firstItem = itemIndex;
 		so->currPos.lastItem = MaxTIDsPerBTreePage - 1;
 		so->currPos.itemIndex = MaxTIDsPerBTreePage - 1;
+
+		if (mergeFiltered)
+		{
+			if (startIndex < 0)
+				return false;
+			so->currPos.itemIndex = startIndex;
+		}
 	}
 
 	/*
diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c
index 0abdd7b49f5..ba72e522566 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -21,6 +21,8 @@
 #include "access/nbtree.h"
 #include "access/relscan.h"
 #include "access/stratnum.h"
+#include "access/nbtxlog.h"
+#include "access/xloginsert.h"
 #include "commands/progress.h"
 #include "commands/vacuum.h"
 #include "nodes/execnodes.h"
@@ -39,6 +41,7 @@
 #include "utils/wait_event.h"
 
 
+
 /*
  * BTPARALLEL_NOT_INITIALIZED indicates that the scan has not started.
  *
@@ -71,6 +74,7 @@ typedef struct BTParallelScanDescData
 	BlockNumber btps_nextScanPage;	/* next page to be scanned */
 	BlockNumber btps_lastCurrPage;	/* page whose sibling link was copied into
 									 * btps_nextScanPage */
+	BlockNumber btps_skipMABlkno;	/* MA tombstone blkno skipped by a worker */
 	BTPS_State	btps_pageStatus;	/* indicates whether next page is
 									 * available for scan. see above for
 									 * possible states of parallel scan. */
@@ -348,7 +352,7 @@ btbeginscan(Relation rel, int nkeys, int norderbys)
 	scan = RelationGetIndexScan(rel, nkeys, norderbys);
 
 	/* allocate private workspace */
-	so = palloc_object(BTScanOpaqueData);
+	so = palloc0_object(BTScanOpaqueData);
 	BTScanPosInvalidate(so->currPos);
 	BTScanPosInvalidate(so->markPos);
 	if (scan->numberOfKeys > 0)
@@ -374,6 +378,15 @@ btbeginscan(Relation rel, int nkeys, int norderbys)
 	 */
 	so->currTuples = so->markTuples = NULL;
 
+	/* Initialize merge fields */
+	so->currPos.skipMergeRecovery = false;
+	so->currPos.needMergeRecovery = false;
+	so->currPos.mergedAwayBlkno = InvalidBlockNumber;
+	so->mergeBoundaryKey = NULL;
+	so->mergeBoundaryItup = NULL;
+	so->mergeBoundaryBlkno = InvalidBlockNumber;
+	so->markBoundaryItup = NULL;
+
 	scan->xs_itupdesc = RelationGetDescr(rel);
 
 	scan->opaque = so;
@@ -427,6 +440,27 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys,
 	BTScanPosUnpinIfPinned(so->markPos);
 	BTScanPosInvalidate(so->markPos);
 
+	so->currPos.skipMergeRecovery = false;
+	so->currPos.needMergeRecovery = false;
+	so->currPos.mergedAwayBlkno = InvalidBlockNumber;
+
+	if (so->mergeBoundaryKey)
+	{
+		pfree(so->mergeBoundaryKey);
+		so->mergeBoundaryKey = NULL;
+	}
+	if (so->mergeBoundaryItup)
+	{
+		pfree(so->mergeBoundaryItup);
+		so->mergeBoundaryItup = NULL;
+	}
+	so->mergeBoundaryBlkno = InvalidBlockNumber;
+	if (so->markBoundaryItup)
+	{
+		pfree(so->markBoundaryItup);
+		so->markBoundaryItup = NULL;
+	}
+
 	/*
 	 * Allocate tuple workspace arrays, if needed for an index-only scan and
 	 * not already done in a previous rescan call.  To save on palloc
@@ -470,6 +504,22 @@ btendscan(IndexScanDesc scan)
 
 	/* No need to invalidate positions, the RAM is about to be freed. */
 
+	if (so->mergeBoundaryKey)
+	{
+		pfree(so->mergeBoundaryKey);
+		so->mergeBoundaryKey = NULL;
+	}
+	if (so->mergeBoundaryItup)
+	{
+		pfree(so->mergeBoundaryItup);
+		so->mergeBoundaryItup = NULL;
+	}
+	if (so->markBoundaryItup)
+	{
+		pfree(so->markBoundaryItup);
+		so->markBoundaryItup = NULL;
+	}
+
 	/* Release storage */
 	if (so->keyData != NULL)
 		pfree(so->keyData);
@@ -556,6 +606,28 @@ btrestrpos(IndexScanDesc scan)
 			if (so->currTuples)
 				memcpy(so->currTuples, so->markTuples,
 					   so->markPos.nextTupleOffset);
+
+			/*
+			 * The marked page needs the boundary of its merge group, but the
+			 * scan may have loaded another group's boundary since the mark.
+			 * Rebuild it from the copy saved with the mark.
+			 */
+			if (so->currPos.needMergeRecovery &&
+				(so->mergeBoundaryKey == NULL ||
+				 so->mergeBoundaryBlkno != so->currPos.mergedAwayBlkno))
+			{
+				Assert(so->markBoundaryItup != NULL);
+
+				if (so->mergeBoundaryKey)
+					pfree(so->mergeBoundaryKey);
+				if (so->mergeBoundaryItup)
+					pfree(so->mergeBoundaryItup);
+
+				so->mergeBoundaryItup = CopyIndexTuple(so->markBoundaryItup);
+				so->mergeBoundaryKey = _bt_mkscankey(scan->indexRelation,
+													 so->mergeBoundaryItup);
+				so->mergeBoundaryBlkno = so->currPos.mergedAwayBlkno;
+			}
 			/* Reset the scan's array keys (see _bt_steppage for why) */
 			if (so->numArrayKeys)
 			{
@@ -820,6 +892,7 @@ btinitparallelscan(void *target)
 	bt_target->btps_nextScanPage = InvalidBlockNumber;
 	bt_target->btps_lastCurrPage = InvalidBlockNumber;
 	bt_target->btps_pageStatus = BTPARALLEL_NOT_INITIALIZED;
+	bt_target->btps_skipMABlkno = InvalidBlockNumber;
 	ConditionVariableInit(&bt_target->btps_cv);
 }
 
@@ -846,6 +919,7 @@ btparallelrescan(IndexScanDesc scan)
 	btscan->btps_nextScanPage = InvalidBlockNumber;
 	btscan->btps_lastCurrPage = InvalidBlockNumber;
 	btscan->btps_pageStatus = BTPARALLEL_NOT_INITIALIZED;
+	btscan->btps_skipMABlkno = InvalidBlockNumber;
 	LWLockRelease(&btscan->btps_lock);
 }
 
@@ -893,6 +967,9 @@ _bt_parallel_seize(IndexScanDesc scan, BlockNumber *next_scan_page,
 	BTScanPosInvalidate(so->currPos);
 	so->currPos.moreLeft = so->currPos.moreRight = true;
 
+	btscan = (BTParallelScanDesc) OffsetToPointer(parallel_scan,
+												  parallel_scan->ps_offset_am);
+
 	if (first)
 	{
 		/*
@@ -913,9 +990,6 @@ _bt_parallel_seize(IndexScanDesc scan, BlockNumber *next_scan_page,
 			return false;
 	}
 
-	btscan = (BTParallelScanDesc) OffsetToPointer(parallel_scan,
-												  parallel_scan->ps_offset_am);
-
 	while (1)
 	{
 		LWLockAcquire(&btscan->btps_lock, LW_EXCLUSIVE);
@@ -1407,7 +1481,8 @@ btvacuumscan(IndexVacuumInfo *info, IndexBulkDeleteResult *stats,
  * a previous call here.  This is how we handle page splits (that happened
  * after our cycleid was acquired) whose right half page happened to reuse
  * a block that we might have processed at some point before it was
- * recycled (i.e. before the page split).
+ * recycled (i.e. before the page split).  Merges that move tuples into an
+ * already scanned page are handled the same way.
  *
  * Returns BlockNumber of a scanned page (not backtracked).
  */
@@ -1518,6 +1593,272 @@ backtrack:
 		 * pages_deleted stats in all cases (barring corruption)
 		 */
 	}
+	else if (P_ISMERGEDAWAY(opaque))
+	{
+		FullTransactionId safemergexid = BTMergedAwayGetSafeXid(page);
+
+		/*
+		 * A merge during this VACUUM may have moved dead tuples from this
+		 * page (not yet visited) into R, a page we already passed.  If so,
+		 * backtrack to R, as for a split.  This must happen even if the
+		 * tombstone is too new to reclaim, and it ignores BTP_SPLIT_END
+		 * because a stale flag may be left from before the merge.
+		 */
+		if (vstate->cycleid != 0 &&
+			opaque->btpo_cycleid == vstate->cycleid &&
+			!P_RIGHTMOST(opaque) &&
+			opaque->btpo_next < scanblkno)
+			backtrack_to = opaque->btpo_next;
+
+		/*
+		 * Only reclaim the tombstone when it is the scan page.  While
+		 * backtracking we must not delete pages, as in the leaf case below.
+		 */
+		if (blkno == scanblkno &&
+			GlobalVisCheckRemovableFullXid(heaprel, safemergexid))
+		{
+			BlockNumber tail_blkno;
+			BlockNumber curr_blkno = opaque->btpo_next;
+			Buffer		curr_buf;
+			BTPageOpaque curr_opaque;
+			Buffer		next_buf;
+			BTPageOpaque next_opaque;
+			BlockNumber next_blkno;
+
+			BlockNumber bwd_blkno;
+			BlockNumber right_anchor;
+			Buffer		bwd_buf;
+			Page		bwd_page,
+						curr_page;
+			BTPageOpaque bwd_opaque;
+			BlockNumber nextblk;
+			BTPageOpaqueData saved_opaque;
+			IndexTupleData trunctuple;
+			XLogRecPtr	recptr;
+
+			/*
+			 * We release the MA page lock here because the forward and
+			 * backward walks acquire write locks on M pages.  Holding the MA
+			 * read lock across multiple buffer lock acquisitions would
+			 * violate PostgreSQL's lock ordering rules and risk deadlock. The
+			 * pin on buf is retained throughout, preventing the MA page from
+			 * being recycled.
+			 */
+			LockBuffer(buf, BUFFER_LOCK_UNLOCK);
+
+			/* Walk Forward to find the tail using lock coupling. */
+			curr_buf = _bt_getbuf(rel, curr_blkno, BT_READ);
+			curr_page = BufferGetPage(curr_buf);
+			curr_opaque = BTPageGetOpaque(curr_page);
+
+			/*
+			 * If the last VACUUM run cleared all M page flags but stopped
+			 * before clearing the MA page flag, skip straight to finalizing
+			 * the tombstone.
+			 */
+			if (!BTPageIsMergedMember(curr_opaque, curr_page, blkno))
+			{
+				_bt_relbuf(rel, curr_buf);
+				goto finalize_ma;
+			}
+
+			while (true)
+			{
+				next_blkno = curr_opaque->btpo_next;
+
+				if (next_blkno == P_NONE)
+				{
+					tail_blkno = curr_blkno;
+					right_anchor = P_NONE;
+					_bt_relbuf(rel, curr_buf);
+					break;
+				}
+
+				next_buf = _bt_getbuf(rel, next_blkno, BT_READ);
+				next_opaque = BTPageGetOpaque(BufferGetPage(next_buf));
+
+				/* End of the merge group reached; stop the forward walk. */
+				if (!BTPageIsMergedMember(next_opaque, BufferGetPage(next_buf), blkno))
+				{
+					tail_blkno = curr_blkno;
+					right_anchor = next_blkno;
+					_bt_relbuf(rel, curr_buf);
+					_bt_relbuf(rel, next_buf);
+					break;
+				}
+
+				_bt_relbuf(rel, curr_buf);
+				curr_blkno = next_blkno;
+				curr_buf = next_buf;
+				curr_opaque = next_opaque;
+			}
+
+			/*
+			 * Walk Backward to clear M flags (Stop before hitting the MA
+			 * tombstone!)
+			 */
+			bwd_blkno = tail_blkno;
+
+			while (bwd_blkno != blkno)
+			{
+				CHECK_FOR_INTERRUPTS();
+
+				bwd_buf = _bt_getbuf(rel, bwd_blkno, BT_WRITE);
+				bwd_page = BufferGetPage(bwd_buf);
+				bwd_opaque = BTPageGetOpaque(bwd_page);
+
+				while (bwd_opaque->btpo_next != right_anchor)
+				{
+					nextblk = bwd_opaque->btpo_next;
+					_bt_relbuf(rel, bwd_buf);
+
+					CHECK_FOR_INTERRUPTS();
+
+					bwd_buf = _bt_getbuf(rel, nextblk, BT_WRITE);
+					bwd_page = BufferGetPage(bwd_buf);
+					bwd_opaque = BTPageGetOpaque(bwd_page);
+
+					if (!BTPageIsMergedMember(bwd_opaque, bwd_page, blkno))
+					{
+						_bt_relbuf(rel, bwd_buf);
+						/* Lock MA page again before skiping cleanup */
+						LockBuffer(buf, BUFFER_LOCK_SHARE);
+						ereport(WARNING,
+								(errmsg("merged-away page %u: unexpected page state near block %u, deferring remaining cleanup to a future VACUUM",
+										blkno, bwd_blkno)));
+
+						goto skip_merge_cleanup;
+					}
+				}
+
+				/*
+				 * If we encounter a page from a different merge group,
+				 * something is wrong; skip the remaining cleanup and defer to
+				 * a future VACUUM.  This case is very unlikely because: -
+				 * When a page splits, the new page also inherits the M flag.
+				 * - No other VACUUM process runs concurrently on the same
+				 * index, so no other process can reset any page in between.
+				 */
+				if (!BTPageIsMergedMember(bwd_opaque, bwd_page, blkno))
+				{
+					_bt_relbuf(rel, bwd_buf);
+
+					/* Lock MA page again before skiping cleanup */
+					LockBuffer(buf, BUFFER_LOCK_SHARE);
+					ereport(WARNING,
+							(errmsg("merged-away page %u: unexpected page state near block %u, deferring remaining cleanup to a future VACUUM",
+									blkno, bwd_blkno)));
+
+					goto skip_merge_cleanup;
+				}
+
+				START_CRIT_SECTION();
+
+				bwd_opaque->btpo_flags &= ~(BTP_MERGED);
+				BTMergedPageClearMABlkno(bwd_page);
+				MarkBufferDirty(bwd_buf);
+
+				if (RelationNeedsWAL(rel))
+				{
+					xl_btree_clear_m xlrec = {0};
+
+					xlrec.safemergexid = safemergexid;
+					xlrec.isCatalogRel = RelationIsAccessibleInLogicalDecoding(heaprel);
+
+					XLogBeginInsert();
+
+					XLogRegisterData(&xlrec, SizeOfBtreeClearM);
+
+					XLogRegisterBuffer(0, bwd_buf, REGBUF_STANDARD);
+
+					recptr = XLogInsert(RM_BTREE2_ID, XLOG_BTREE2_CLEAR_MERGE_FLAG);
+				}
+				else
+					recptr = XLogGetFakeLSN(rel);
+
+				PageSetLSN(bwd_page, recptr);
+
+				END_CRIT_SECTION();
+
+				right_anchor = BufferGetBlockNumber(bwd_buf);
+				bwd_blkno = bwd_opaque->btpo_prev;
+				_bt_relbuf(rel, bwd_buf);
+			}
+
+	finalize_ma:
+
+			/* We are back at the tombstone(MA) to make it HD */
+
+			/* Upgrade our read lock to a write lock while keeping the pin! */
+			LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE);
+
+			if (!P_ISMERGEDAWAY(opaque) ||
+				!FullTransactionIdEquals(BTMergedAwayGetSafeXid(page), safemergexid))
+			{
+				/*
+				 * Downgrade from exclusive back to share lock before jumping
+				 * to skip_merge_cleanup.  The pin is still held, so the brief
+				 * unlock window is safe.  btvacuumpage and its caller expect
+				 * buf to exit with a lock held.
+				 */
+				LockBuffer(buf, BUFFER_LOCK_UNLOCK);
+				LockBuffer(buf, BUFFER_LOCK_SHARE);
+				ereport(WARNING,
+						(errmsg("merged-away page %u changed concurrently, skipping cleanup",
+								blkno)));
+				goto skip_merge_cleanup;
+			}
+
+			/* _bt_pagedel() stage 2 requires a high key to exist on the page. */
+
+			saved_opaque = *opaque;
+
+			MemSet(&trunctuple, 0, sizeof(IndexTupleData));
+			trunctuple.t_info = sizeof(IndexTupleData);
+			BTreeTupleSetTopParent(&trunctuple, InvalidBlockNumber);
+
+			START_CRIT_SECTION();
+
+			PageInit(page, BufferGetPageSize(buf), sizeof(BTPageOpaqueData));
+
+			if (PageAddItem(page, (IndexTuple) &trunctuple, IndexTupleSize(&trunctuple),
+							P_HIKEY, false, false) == InvalidOffsetNumber)
+				elog(PANIC, "could not add dummy high key to half-dead page");
+
+			opaque = BTPageGetOpaque(page);
+			*opaque = saved_opaque;
+			opaque->btpo_flags &= ~(BTP_MERGED_AWAY | BTP_HAS_FULLXID);
+			opaque->btpo_flags |= BTP_HALF_DEAD;
+			MarkBufferDirty(buf);
+
+
+			if (RelationNeedsWAL(rel))
+			{
+				xl_btree_mark_ma_hd xlrec = {0};
+
+				xlrec.left_prev = opaque->btpo_prev;
+				xlrec.left_next = opaque->btpo_next;
+				xlrec.safemergexid = safemergexid;
+				xlrec.isCatalogRel = RelationIsAccessibleInLogicalDecoding(heaprel);
+
+				XLogBeginInsert();
+
+				XLogRegisterData(&xlrec, SizeOfBtreeMarkMaHd);
+
+				XLogRegisterBuffer(0, buf, REGBUF_WILL_INIT);
+
+				recptr = XLogInsert(RM_BTREE2_ID, XLOG_BTREE2_MERGE_MARK_HALFDEAD);
+			}
+			else
+				recptr = XLogGetFakeLSN(rel);
+
+			PageSetLSN(page, recptr);
+
+			END_CRIT_SECTION();
+
+			attempt_pagedel = true;
+		}
+	}
 	else if (P_ISLEAF(opaque))
 	{
 		OffsetNumber deletable[MaxIndexTuplesPerPage];
@@ -1538,6 +1879,8 @@ backtrack:
 		 */
 		_bt_upgradelockbufcleanup(rel, buf);
 
+		Assert(!P_ISMERGEDAWAY(opaque));
+
 		/*
 		 * Check whether we need to backtrack to earlier pages.  What we are
 		 * concerned about is a page split that happened since we started the
@@ -1695,6 +2038,8 @@ backtrack:
 		Assert(!attempt_pagedel || nhtidslive == 0);
 	}
 
+skip_merge_cleanup:
+
 	if (attempt_pagedel)
 	{
 		MemoryContext oldcontext;
@@ -1852,3 +2197,47 @@ bttranslatecmptype(CompareType cmptype, Oid opfamily)
 			return InvalidStrategy;
 	}
 }
+
+/*
+ * Store in the shared parallel scan state the tombstone of the merge group a
+ * process has passed.  Other processes use it to tell whether L's tuples were
+ * already returned.  Does nothing for a serial scan.
+ */
+void
+_bt_parallel_set_skip_ma(IndexScanDesc scan, BlockNumber blkno)
+{
+	ParallelIndexScanDesc parallel_scan = scan->parallel_scan;
+	BTParallelScanDesc btscan;
+
+	if (scan->parallel_scan == NULL)
+		return;
+
+	btscan = (BTParallelScanDesc) OffsetToPointer(parallel_scan,
+												  parallel_scan->ps_offset_am);
+
+	LWLockAcquire(&btscan->btps_lock, LW_EXCLUSIVE);
+	btscan->btps_skipMABlkno = blkno;
+	LWLockRelease(&btscan->btps_lock);
+}
+
+/*
+ * Return the tombstone stored by _bt_parallel_set_skip_ma(), or
+ * InvalidBlockNumber for a serial scan.
+ */
+BlockNumber
+_bt_parallel_get_skip_ma(IndexScanDesc scan)
+{
+	ParallelIndexScanDesc parallel_scan = scan->parallel_scan;
+	BTParallelScanDesc btscan;
+	BlockNumber blkno;
+
+	if (!scan->parallel_scan)
+		return InvalidBlockNumber;
+
+	btscan = (BTParallelScanDesc) OffsetToPointer(parallel_scan,
+												  parallel_scan->ps_offset_am);
+	LWLockAcquire(&btscan->btps_lock, LW_SHARED);
+	blkno = btscan->btps_skipMABlkno;
+	LWLockRelease(&btscan->btps_lock);
+	return blkno;
+}
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..44b8b9ce90b 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -45,7 +45,10 @@ static bool _bt_readnextpage(IndexScanDesc scan, BlockNumber blkno,
 static Buffer _bt_lock_and_validate_left(Relation rel, BlockNumber *blkno,
 										 BlockNumber lastcurrblkno);
 static bool _bt_endpoint(IndexScanDesc scan, ScanDirection dir);
-
+static void _bt_find_merge_tail(IndexScanDesc scan, BlockNumber ma_blkno,
+								BlockNumber m_blkno, BlockNumber *blkno,
+								BlockNumber *lastcurrblkno);
+static void _bt_load_boundary_from_ma(IndexScanDesc scan, Buffer mabuf);
 
 /*
  *	_bt_drop_lock_and_maybe_pin()
@@ -306,7 +309,7 @@ _bt_moveright(Relation rel,
 			continue;
 		}
 
-		if (P_IGNORE(opaque) || _bt_compare(rel, key, page, P_HIKEY) >= cmpval)
+		if (P_IGNORE(opaque) || P_ISMERGEDAWAY(opaque) || _bt_compare(rel, key, page, P_HIKEY) >= cmpval)
 		{
 			/* step right one page */
 			buf = _bt_relandgetbuf(rel, buf, opaque->btpo_next, access);
@@ -1681,6 +1684,18 @@ _bt_steppage(IndexScanDesc scan, ScanDirection dir)
 		so->markPos.itemIndex = so->markItemIndex;
 		so->markItemIndex = -1;
 
+		/* Save the boundary too, for btrestrpos to rebuild if it changes */
+		if (so->markBoundaryItup)
+		{
+			pfree(so->markBoundaryItup);
+			so->markBoundaryItup = NULL;
+		}
+		if (so->currPos.needMergeRecovery)
+		{
+			Assert(so->mergeBoundaryItup != NULL);
+			so->markBoundaryItup = CopyIndexTuple(so->mergeBoundaryItup);
+		}
+
 		/*
 		 * If we're just about to start the next primitive index scan
 		 * (possible with a scan that has arrays keys, and needs to skip to
@@ -1711,6 +1726,17 @@ _bt_steppage(IndexScanDesc scan, ScanDirection dir)
 
 	BTScanPosUnpinIfPinned(so->currPos);
 
+	/*
+	 * The scan turned on a merge-filtered copy.  The other merged pages in
+	 * the new direction are all on the far side of the cursor, so read them
+	 * in full and skip the tombstone.
+	 */
+	if (so->currPos.mergeFiltered && so->currPos.dir != dir)
+	{
+		so->currPos.needMergeRecovery = false;
+		so->currPos.skipMergeRecovery = true;
+	}
+
 	/* Walk to the next page with data */
 	if (ScanDirectionIsForward(dir))
 		blkno = so->currPos.nextPage;
@@ -1755,6 +1781,35 @@ _bt_readfirstpage(IndexScanDesc scan, OffsetNumber offnum, ScanDirection dir)
 {
 	BTScanOpaque so = (BTScanOpaque) scan->opaque;
 
+	/*
+	 * If the starting page has BTP_MERGED, we descended directly into a merge
+	 * group (rather than stepping from a MERGED_AWAY tombstone).  Set
+	 * skipMergeRecovery so the scan loop knows no deduplication is needed for
+	 * the subsequent MERGED pages in this group.
+	 *
+	 * so->currPos.buf is already pinned and locked on entry, so we can safely
+	 * read the page opaque here without any extra locking.
+	 */
+	{
+		Page		page = BufferGetPage(so->currPos.buf);
+		BTPageOpaque opaque = BTPageGetOpaque(page);
+
+		/*
+		 * A new descent starts outside any merge group.  The scan may have
+		 * ended inside a group with recovery still on.
+		 */
+		so->currPos.needMergeRecovery = false;
+		so->currPos.skipMergeRecovery = false;
+		so->currPos.mergedAwayBlkno = InvalidBlockNumber;
+
+		if (P_ISMERGED(opaque))
+		{
+			so->currPos.skipMergeRecovery = true;
+			so->currPos.mergedAwayBlkno = BTMergedPageGetMABlkno(page);
+			_bt_parallel_set_skip_ma(scan, so->currPos.mergedAwayBlkno);
+		}
+	}
+
 	so->numKilled = 0;			/* just paranoia */
 	so->markItemIndex = -1;		/* ditto */
 
@@ -1849,11 +1904,15 @@ _bt_readnextpage(IndexScanDesc scan, BlockNumber blkno,
 {
 	Relation	rel = scan->indexRelation;
 	BTScanOpaque so = (BTScanOpaque) scan->opaque;
+	bool		needMergeRecoverWalk = false;
+	BlockNumber m_blkno = InvalidBlockNumber;
 
 	Assert(so->currPos.currPage == lastcurrblkno || seized);
 	Assert(!(blkno == P_NONE && seized));
 	Assert(!BTScanPosIsPinned(so->currPos));
 
+	INJECTION_POINT("nbtree-read-next-page", RelationGetRelationName(rel));
+
 	/*
 	 * Remember that the scan already read lastcurrblkno, a page to the left
 	 * of blkno (or remember reading a page to the right, for backwards scans)
@@ -1868,6 +1927,8 @@ _bt_readnextpage(IndexScanDesc scan, BlockNumber blkno,
 		Page		page;
 		BTPageOpaque opaque;
 
+		needMergeRecoverWalk = false;
+
 		if (blkno == P_NONE ||
 			(ScanDirectionIsForward(dir) ?
 			 !so->currPos.moreRight : !so->currPos.moreLeft))
@@ -1888,6 +1949,7 @@ _bt_readnextpage(IndexScanDesc scan, BlockNumber blkno,
 
 		/* parallel scan must never actually visit so->currPos blkno */
 		if (!seized && scan->parallel_scan != NULL &&
+			!(ScanDirectionIsBackward(dir) && so->currPos.needMergeRecovery) &&
 			!_bt_parallel_seize(scan, &blkno, &lastcurrblkno, false))
 		{
 			/* whole scan is now done (or another primitive scan required) */
@@ -1918,8 +1980,20 @@ _bt_readnextpage(IndexScanDesc scan, BlockNumber blkno,
 		page = BufferGetPage(so->currPos.buf);
 		opaque = BTPageGetOpaque(page);
 		lastcurrblkno = blkno;
-		if (likely(!P_IGNORE(opaque)))
+
+
+		if (likely(!P_IGNORE(opaque) && !P_ISMERGEDAWAY(opaque) && !P_ISMERGED(opaque)))
 		{
+			/*
+			 * We landed on a normal page. If we were in a merge group, we
+			 * have now exited it. Clear the merge recovery flags so we are
+			 * ready for the next merge group.  Keep the boundary key, as a
+			 * restore to a mark inside the group may need it again.
+			 */
+			so->currPos.needMergeRecovery = false;
+			so->currPos.skipMergeRecovery = false;
+			so->currPos.mergedAwayBlkno = InvalidBlockNumber;
+
 			/* see if there are any matches on this page */
 			if (ScanDirectionIsForward(dir))
 			{
@@ -1936,7 +2010,7 @@ _bt_readnextpage(IndexScanDesc scan, BlockNumber blkno,
 				blkno = so->currPos.prevPage;
 			}
 		}
-		else
+		else if (unlikely(P_IGNORE(opaque)))
 		{
 			/* _bt_readpage not called, so do all this for ourselves */
 			if (ScanDirectionIsForward(dir))
@@ -1946,10 +2020,162 @@ _bt_readnextpage(IndexScanDesc scan, BlockNumber blkno,
 			if (scan->parallel_scan != NULL)
 				_bt_parallel_release(scan, blkno, lastcurrblkno);
 		}
+		else if (P_ISMERGEDAWAY(opaque))
+		{
+			if (ScanDirectionIsForward(dir))
+			{
+				/*
+				 * Save state indicating we passed a merged away page and
+				 * proceed to the next page
+				 */
+				_bt_parallel_set_skip_ma(scan, blkno);
+				so->currPos.skipMergeRecovery = true;
+				so->currPos.mergedAwayBlkno = blkno;
+
+				blkno = opaque->btpo_next;
+
+				if (scan->parallel_scan != NULL)
+					_bt_parallel_release(scan, blkno, lastcurrblkno);
+			}
+			else
+			{
+				bool		MRead;
+
+				if (scan->parallel_scan != NULL)
+					MRead = (_bt_parallel_get_skip_ma(scan) == blkno);
+				else
+					MRead = (so->currPos.skipMergeRecovery && so->currPos.mergedAwayBlkno == blkno);
+
+				/*
+				 * BACKWARD SCAN: Tombstone (BTP_MERGED_AWAY)
+				 */
+				if (MRead || so->currPos.needMergeRecovery)
+				{
+					/*
+					 * We either already read the merged page or we just
+					 * finished walking backward through the recovery group.
+					 * In either case, we safely skip this tombstone and step
+					 * left.
+					 */
+					blkno = opaque->btpo_prev;
+
+					/*
+					 * Clear the states since we are exiting the merge group.
+					 * Keep the boundary key for a restore into the group.
+					 */
+					so->currPos.skipMergeRecovery = false;
+					so->currPos.needMergeRecovery = false;
+					so->currPos.mergedAwayBlkno = InvalidBlockNumber;
+
+					if (scan->parallel_scan != NULL)
+						_bt_parallel_release(scan, blkno, lastcurrblkno);
+				}
+				else
+				{
+					/*
+					 * We hit the tombstone but haven't seen the merged page
+					 * yet! Enter recovery mode to find the merged tuples.
+					 */
+					_bt_load_boundary_from_ma(scan, so->currPos.buf);
+					so->currPos.mergedAwayBlkno = blkno;
+
+					if (scan->parallel_scan != NULL)
+						_bt_parallel_set_skip_ma(scan, blkno);
+
+					/*
+					 * Trigger the rightward walk at the end of the loop to
+					 * find the tail page
+					 */
+					needMergeRecoverWalk = true;
+					/* The start of the merged group */
+					m_blkno = opaque->btpo_next;
+				}
+			}
+		}
+		else if (P_ISMERGED(opaque))
+		{
+			if (ScanDirectionIsForward(dir))
+			{
+				bool		MASkiped;
+				BlockNumber MABlkno = BTMergedPageGetMABlkno(page);
+
+				if (scan->parallel_scan != NULL)
+					MASkiped = (_bt_parallel_get_skip_ma(scan) == MABlkno);
+				else
+					MASkiped = (so->currPos.skipMergeRecovery && so->currPos.mergedAwayBlkno == MABlkno);
+
+				if (MASkiped)
+				{
+					/*
+					 * L was skipped as a tombstone; read all data on this
+					 * page
+					 */
+					so->currPos.needMergeRecovery = false;
+				}
+				else
+				{
+					bool		loadBoundary = (so->mergeBoundaryBlkno != MABlkno || so->mergeBoundaryKey == NULL);
+
+					if (loadBoundary)
+					{
+						Buffer		mabuf;
+
+						/* Unlock R to avoid deadlock */
+						LockBuffer(so->currPos.buf, BUFFER_LOCK_UNLOCK);
+
+						/* Read L and load boundary */
+						mabuf = _bt_getbuf(rel, MABlkno, BT_READ);
+						_bt_load_boundary_from_ma(scan, mabuf);
+						_bt_relbuf(rel, mabuf);
+
+						/* Re-lock R */
+						LockBuffer(so->currPos.buf, BT_READ);
+						page = BufferGetPage(so->currPos.buf);
+						opaque = BTPageGetOpaque(page);
+						Assert(!P_IGNORE(opaque));
+					}
+					so->currPos.mergedAwayBlkno = MABlkno;
+
+					/*
+					 * The boundary may already be cached from an earlier
+					 * visit to this group, e.g. after a mark/restore, so turn
+					 * the filter on here rather than only when loading it.
+					 */
+					so->currPos.needMergeRecovery = true;
+				}
+				if (_bt_readpage(scan, dir, P_FIRSTDATAKEY(opaque), seized))
+					break;
+				blkno = so->currPos.nextPage;
+			}
+			else
+			{
+				so->currPos.mergedAwayBlkno = BTMergedPageGetMABlkno(page);
+				_bt_parallel_set_skip_ma(scan, so->currPos.mergedAwayBlkno);
+
+				if (!so->currPos.needMergeRecovery)
+					so->currPos.skipMergeRecovery = true;
+
+				if (_bt_readpage(scan, dir, PageGetMaxOffsetNumber(page), seized))
+					break;
+
+				blkno = so->currPos.prevPage;
+			}
+		}
 
 		/* no matching tuples on this page */
 		_bt_relbuf(rel, so->currPos.buf);
 		seized = false;			/* released by _bt_readpage (or by us) */
+
+		if (needMergeRecoverWalk)
+		{
+			_bt_find_merge_tail(scan, blkno, m_blkno, &blkno, &lastcurrblkno);
+
+			/*
+			 * After this call the loop will continue to read blkno page * so
+			 * we now have to how these MERGED pages are going to be read
+			 */
+			needMergeRecoverWalk = false;
+		}
 	}
 
 	/*
@@ -1960,9 +2186,160 @@ _bt_readnextpage(IndexScanDesc scan, BlockNumber blkno,
 	Assert(BTScanPosIsPinned(so->currPos));
 	_bt_drop_lock_and_maybe_pin(rel, so);
 
+
 	return true;
 }
 
+/*
+ * _bt_load_boundary_from_ma() -- Read the merge boundary from the MA tombstone
+ * page and load it into so->mergeBoundaryKey.  Also turns on the merged page
+ * filter (needMergeRecovery).
+ */
+static void
+_bt_load_boundary_from_ma(IndexScanDesc scan, Buffer mabuf)
+{
+	BTScanOpaque so = (BTScanOpaque) scan->opaque;
+	Page		mapage = BufferGetPage(mabuf);
+	BTPageOpaque maopaque = BTPageGetOpaque(mapage);
+	BTMergedAwayPageData *contents;
+	IndexTuple	itup;
+
+	Assert(P_ISMERGEDAWAY(maopaque));
+
+	contents = (BTMergedAwayPageData *) PageGetContents(mapage);
+	itup = (IndexTuple) (((char *) contents) + MAXALIGN(sizeof(BTMergedAwayPageData)));
+
+	if (so->mergeBoundaryKey)
+	{
+		pfree(so->mergeBoundaryKey);
+		so->mergeBoundaryKey = NULL;
+	}
+	if (so->mergeBoundaryItup)
+	{
+		pfree(so->mergeBoundaryItup);
+		so->mergeBoundaryItup = NULL;
+	}
+
+	so->mergeBoundaryItup = CopyIndexTuple(itup);
+	so->mergeBoundaryKey = _bt_mkscankey(scan->indexRelation, so->mergeBoundaryItup);
+	so->mergeBoundaryBlkno = BufferGetBlockNumber(mabuf);
+
+	so->currPos.needMergeRecovery = true;
+}
+
+/*
+ *	_bt_find_merge_tail() -- Find the tail of a MERGED page group for backward scan recovery.
+ *
+ * During a backward scan, when the scan discovers it needs merge recovery
+ * (needMergeRecovery), it must walk forward from the first MERGED page (R)
+ * to locate the rightmost page in the merge group (the "tail").  This is
+ * needed so the backward scan can restart from the tail and walk backward
+ * through the full merge group, returning only tuples at or below the
+ * boundary key.
+ *
+ * ma_blkno is the MERGED_AWAY tombstone and m_blkno is its right sibling,
+ * normally the first MERGED page.  On return, *blkno is set to the
+ * tail of the merge group (the last consecutive MERGED page), and
+ * *lastcurrblkno is set to its right neighbor (the first non-MERGED page),
+ * which is used as the right anchor for split validation during the
+ * subsequent backward scan.
+ *
+ * VACUUM can page-delete every merged page of a group while the tombstone
+ * stays, so m_blkno may not be one of the tombstone's merged pages.  Then
+ * there is nothing to recover: *blkno is set to the tombstone itself and
+ * *lastcurrblkno to m_blkno, and the caller steps past the tombstone since
+ * needMergeRecovery is already set.
+ *
+ * Uses lock-coupling (acquire right, release left) to traverse the chain
+ * safely under concurrent activity.
+ */
+static void
+_bt_find_merge_tail(IndexScanDesc scan, BlockNumber ma_blkno,
+					BlockNumber m_blkno, BlockNumber *blkno,
+					BlockNumber *lastcurrblkno)
+{
+	Relation	rel = scan->indexRelation;
+	BlockNumber r_blkno,
+				l_blkno;
+	Buffer		r_buf,
+				l_buf;
+	Page		r_page,
+				l_page;
+	BTPageOpaque r_opaque,
+				l_opaque;
+
+	/* L <--> R(r_blkno) <--> X <--> Y(tail) <--> Z (first non MERGED page) */
+
+	/*
+	 * Step 1: Initialize the left page. m_blkno is the first merged page (R)
+	 * we got from the tombstone's btpo_next.
+	 */
+	l_blkno = m_blkno;
+	l_buf = _bt_getbuf(rel, l_blkno, BT_READ);
+	l_page = BufferGetPage(l_buf);
+	l_opaque = BTPageGetOpaque(l_page);
+
+	if (!BTPageIsMergedMember(l_opaque, l_page, ma_blkno))
+	{
+		*blkno = ma_blkno;
+		*lastcurrblkno = m_blkno;
+		_bt_relbuf(rel, l_buf);
+		return;
+	}
+
+	/*
+	 * Step 2: Lock-couple rightward to find the boundary. Loop till we find a
+	 * non-MERGED page or the rightmost edge.
+	 */
+	for (;;)
+	{
+		CHECK_FOR_INTERRUPTS();
+		r_blkno = l_opaque->btpo_next;
+
+		/*
+		 * Check if the merged group extends to the rightmost edge of the
+		 * index
+		 */
+		if (r_blkno == P_NONE)
+		{
+			*blkno = l_blkno;
+			*lastcurrblkno = P_NONE;	/* No page to the right, so no right
+										 * anchor */
+			_bt_relbuf(rel, l_buf);
+			break;
+		}
+
+		/* Lock the right page */
+		r_buf = _bt_getbuf(rel, r_blkno, BT_READ);
+		r_page = BufferGetPage(r_buf);
+		r_opaque = BTPageGetOpaque(r_page);
+
+		/* Check if we found the boundary (the first non-merged page) */
+		if (!P_ISMERGED(r_opaque))
+		{
+			*blkno = l_blkno;	/* The tail of the merged group */
+			*lastcurrblkno = r_blkno;	/* The right anchor for validation */
+
+			/* We have our boundary. Unlock both pages before returning. */
+			_bt_relbuf(rel, l_buf);
+			_bt_relbuf(rel, r_buf);
+
+			break;
+		}
+
+		/* The right page is also merged, so shift right (lock-coupling) */
+
+		/* Unlock the old left page */
+		_bt_relbuf(rel, l_buf);
+
+		/* Make the right page the new left page for the next iteration */
+		l_blkno = r_blkno;
+		l_buf = r_buf;
+		l_page = r_page;
+		l_opaque = r_opaque;
+	}
+}
+
 /*
  * _bt_lock_and_validate_left() -- lock caller's left sibling blkno,
  * recovering from concurrent page splits/page deletions when necessary
@@ -2155,7 +2532,7 @@ _bt_get_endpoint(Relation rel, uint32 level, bool rightmost)
 		 * right if needed to get to it (this could happen if the page split
 		 * since we obtained a pointer to it).
 		 */
-		while (P_IGNORE(opaque) ||
+		while (P_IGNORE(opaque) || P_ISMERGEDAWAY(opaque) ||
 			   (rightmost && !P_RIGHTMOST(opaque)))
 		{
 			blkno = opaque->btpo_next;
diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c
index 85c1b632522..54526dac154 100644
--- a/src/backend/access/nbtree/nbtutils.c
+++ b/src/backend/access/nbtree/nbtutils.c
@@ -254,6 +254,14 @@ _bt_killitems(IndexScanDesc scan)
 
 	page = BufferGetPage(buf);
 	opaque = BTPageGetOpaque(page);
+
+	/*
+	 * bt_merge() can convert a page to BTP_MERGED_AWAY while we hold a pin
+	 * but no lock.  The original tuples are gone; give up on hinting.
+	 */
+	if (P_ISMERGEDAWAY(opaque))
+		goto unlock_page;
+
 	minoff = P_FIRSTDATAKEY(opaque);
 	maxoff = PageGetMaxOffsetNumber(page);
 
@@ -967,7 +975,7 @@ _bt_check_natts(Relation rel, bool heapkeyspace, Page page, OffsetNumber offnum)
 	 * We cannot reliably test a deleted or half-dead page, since they have
 	 * dummy high keys
 	 */
-	if (P_IGNORE(opaque))
+	if (P_IGNORE(opaque) || P_ISMERGEDAWAY(opaque))
 		return true;
 
 	Assert(offnum >= FirstOffsetNumber &&
diff --git a/src/backend/access/nbtree/nbtxlog.c b/src/backend/access/nbtree/nbtxlog.c
index dff7d286fc8..78159756304 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -258,6 +258,7 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
 	BlockNumber origpagenumber;
 	BlockNumber rightpagenumber;
 	BlockNumber spagenumber;
+	BlockNumber merged_ma_blkno = xlrec->merged_ma_blkno;
 
 	XLogRecGetBlockTag(record, 0, NULL, NULL, &origpagenumber);
 	XLogRecGetBlockTag(record, 1, NULL, NULL, &rightpagenumber);
@@ -294,6 +295,12 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
 	ropaque->btpo_flags = isleaf ? BTP_LEAF : 0;
 	ropaque->btpo_cycleid = 0;
 
+	if (BlockNumberIsValid(merged_ma_blkno))
+	{
+		ropaque->btpo_flags |= BTP_MERGED;
+		BTMergedPageSetMABlkno(rpage, merged_ma_blkno);
+	}
+
 	_bt_restore_page(rpage, datapos, datalen);
 
 	PageSetLSN(rpage, lsn);
@@ -415,6 +422,13 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
 		oopaque->btpo_flags = BTP_INCOMPLETE_SPLIT;
 		if (isleaf)
 			oopaque->btpo_flags |= BTP_LEAF;
+
+		if (BlockNumberIsValid(merged_ma_blkno))
+		{
+			oopaque->btpo_flags |= BTP_MERGED;
+			BTMergedPageSetMABlkno(origpage, merged_ma_blkno);
+		}
+
 		oopaque->btpo_next = rightpagenumber;
 		oopaque->btpo_cycleid = 0;
 
@@ -487,6 +501,14 @@ btree_xlog_dedup(XLogReaderState *record)
 		maxoff = PageGetMaxOffsetNumber(page);
 		newpage = PageGetTempPageCopySpecial(page);
 
+		if (BlockNumberIsValid(xlrec->merged_ma_blkno))
+		{
+			BTPageOpaque nopaque = BTPageGetOpaque(newpage);
+
+			nopaque->btpo_flags |= BTP_MERGED;
+			BTMergedPageSetMABlkno(newpage, xlrec->merged_ma_blkno);
+		}
+
 		if (!P_RIGHTMOST(opaque))
 		{
 			ItemId		itemid = PageGetItemId(page, P_HIKEY);
@@ -710,6 +732,7 @@ btree_xlog_mark_page_halfdead(uint8 info, XLogReaderState *record)
 	Page		page;
 	BTPageOpaque pageop;
 	IndexTupleData trunctuple;
+	BlockNumber merged_ma_blkno = xlrec->merged_ma_blkno;
 
 	/*
 	 * In normal operation, we would lock all the pages this WAL record
@@ -768,6 +791,12 @@ btree_xlog_mark_page_halfdead(uint8 info, XLogReaderState *record)
 	pageop->btpo_flags = BTP_HALF_DEAD | BTP_LEAF;
 	pageop->btpo_cycleid = 0;
 
+	if (BlockNumberIsValid(merged_ma_blkno))
+	{
+		pageop->btpo_flags |= BTP_MERGED;
+		BTMergedPageSetMABlkno(page, merged_ma_blkno);
+	}
+
 	/*
 	 * Construct a dummy high key item that points to top parent page (value
 	 * is InvalidBlockNumber when the top parent page is the leaf page itself)
@@ -800,6 +829,7 @@ btree_xlog_unlink_page(uint8 info, XLogReaderState *record)
 	Buffer		rightbuf;
 	Page		page;
 	BTPageOpaque pageop;
+	BlockNumber merged_ma_blkno = xlrec->merged_ma_blkno;
 
 	leftsib = xlrec->leftsib;
 	rightsib = xlrec->rightsib;
@@ -849,6 +879,12 @@ btree_xlog_unlink_page(uint8 info, XLogReaderState *record)
 		pageop->btpo_flags |= BTP_LEAF;
 	pageop->btpo_cycleid = 0;
 
+	if (isleaf && BlockNumberIsValid(merged_ma_blkno))
+	{
+		pageop->btpo_flags |= BTP_MERGED;
+		BTMergedPageSetMABlkno(page, merged_ma_blkno);
+	}
+
 	PageSetLSN(page, lsn);
 	MarkBufferDirty(target);
 
@@ -905,6 +941,12 @@ btree_xlog_unlink_page(uint8 info, XLogReaderState *record)
 		pageop->btpo_level = 0;
 		pageop->btpo_cycleid = 0;
 
+		if (BlockNumberIsValid(merged_ma_blkno))
+		{
+			pageop->btpo_flags |= BTP_MERGED;
+			BTMergedPageSetMABlkno(page, merged_ma_blkno);
+		}
+
 		/* Add a dummy hikey item */
 		MemSet(&trunctuple, 0, sizeof(IndexTupleData));
 		trunctuple.t_info = sizeof(IndexTupleData);
@@ -1000,6 +1042,174 @@ btree_xlog_reuse_page(XLogReaderState *record)
 												   xlrec->locator);
 }
 
+/* Redo a merge: L becomes a tombstone, R gets L's tuples, parent updated */
+static void
+btree_xlog_merge_page(XLogReaderState *record)
+{
+	xl_btree_merge *xlrec = (xl_btree_merge *) XLogRecGetData(record);
+	XLogRecPtr	lsn = record->EndRecPtr;
+	Buffer		buf;
+	Buffer		rbuf = InvalidBuffer;
+	Buffer		pbuf = InvalidBuffer;
+	Page		page;
+	BTPageOpaque pageop;
+	char	   *boundary_pivot;
+	Size		boundary_sz;
+
+	/*
+	 * Reconstruct left (MA) page from scratch.  Take a cleanup lock, as the
+	 * merge did on the primary, so a standby scan that still holds L's items
+	 * forces a recovery conflict instead of losing them.
+	 */
+	XLogReadBufferForRedoExtended(record, 0, RBM_ZERO_AND_CLEANUP_LOCK, true, &buf);
+	page = BufferGetPage(buf);
+
+	_bt_pageinit(page, BufferGetPageSize(buf));
+	pageop = BTPageGetOpaque(page);
+	pageop->btpo_prev = xlrec->left_prev;
+	pageop->btpo_next = xlrec->left_next;
+	pageop->btpo_level = 0;
+	pageop->btpo_flags = BTP_LEAF;
+	pageop->btpo_cycleid = 0;
+
+	boundary_pivot = XLogRecGetBlockData(record, 0, &boundary_sz);
+	Assert(boundary_pivot != NULL && boundary_sz > 0);
+
+	BTPageSetMergedAway(page, xlrec->safemergexid, (IndexTuple) boundary_pivot, boundary_sz);
+	PageSetLSN(page, lsn);
+	MarkBufferDirty(buf);
+
+	/* Restore right (M) page */
+	XLogReadBufferForRedo(record, 1, &rbuf);
+
+	/* Update parent page */
+	if (XLogReadBufferForRedo(record, 2, &pbuf) == BLK_NEEDS_REDO)
+	{
+		Page		ppage = BufferGetPage(pbuf);
+		OffsetNumber poffset;
+		ItemId		itemid;
+		IndexTuple	itup;
+		OffsetNumber nextoffset;
+		BlockNumber rightsib;
+
+		poffset = xlrec->poffset;
+		nextoffset = OffsetNumberNext(poffset);
+		itemid = PageGetItemId(ppage, nextoffset);
+		itup = (IndexTuple) PageGetItem(ppage, itemid);
+		rightsib = BTreeTupleGetDownLink(itup);
+
+		itemid = PageGetItemId(ppage, poffset);
+		itup = (IndexTuple) PageGetItem(ppage, itemid);
+
+		BTreeTupleSetDownLink(itup, rightsib);
+		nextoffset = OffsetNumberNext(poffset);
+		PageIndexTupleDelete(ppage, nextoffset);
+		PageSetLSN(ppage, lsn);
+		MarkBufferDirty(pbuf);
+	}
+
+	/*
+	 * Finally, release all buffers together so that readers cannot observe
+	 * inconsistencies.
+	 */
+	if (BufferIsValid(pbuf))
+		UnlockReleaseBuffer(pbuf);
+	if (BufferIsValid(rbuf))
+		UnlockReleaseBuffer(rbuf);
+	UnlockReleaseBuffer(buf);
+}
+
+/* Redo clearing the merged flag on one page of a merge group */
+static void
+btree_xlog_clear_m_flag(XLogReaderState *record)
+{
+	xl_btree_clear_m *xlrec = (xl_btree_clear_m *) XLogRecGetData(record);
+	XLogRecPtr	lsn = record->EndRecPtr;
+	Buffer		buf;
+	Page		page;
+	BTPageOpaque pageop;
+
+	/*
+	 * If we have any conflict processing to do, it must happen before we
+	 * update the page
+	 */
+	if (InHotStandby)
+	{
+		RelFileLocator rlocator;
+
+		XLogRecGetBlockTag(record, 0, &rlocator, NULL, NULL);
+
+		ResolveRecoveryConflictWithSnapshotFullXid(
+												   xlrec->safemergexid,
+												   xlrec->isCatalogRel,
+												   rlocator);
+	}
+
+	if (XLogReadBufferForRedo(record, 0, &buf) == BLK_NEEDS_REDO)
+	{
+		page = BufferGetPage(buf);
+		pageop = BTPageGetOpaque(page);
+		BTMergedPageClearMABlkno(page);
+		pageop->btpo_flags &= ~BTP_MERGED;
+		PageSetLSN(page, lsn);
+		MarkBufferDirty(buf);
+	}
+	if (BufferIsValid(buf))
+		UnlockReleaseBuffer(buf);
+}
+
+/* Redo marking a tombstone half-dead, after its group was cleaned up */
+static void
+btree_xlog_mark_ma_hd(XLogReaderState *record)
+{
+	xl_btree_mark_ma_hd *xlrec = (xl_btree_mark_ma_hd *) XLogRecGetData(record);
+	XLogRecPtr	lsn = record->EndRecPtr;
+	Buffer		buf;
+	Page		page;
+	BTPageOpaque pageop;
+	IndexTupleData trunctuple;
+
+	/*
+	 * If we have any conflict processing to do, it must happen before we
+	 * update the page
+	 */
+	if (InHotStandby)
+	{
+		RelFileLocator rlocator;
+
+		XLogRecGetBlockTag(record, 0, &rlocator, NULL, NULL);
+
+		ResolveRecoveryConflictWithSnapshotFullXid(
+												   xlrec->safemergexid,
+												   xlrec->isCatalogRel,
+												   rlocator);
+	}
+
+	/* Rewrite the MA page as a halfdead page */
+	buf = XLogInitBufferForRedo(record, 0);
+	page = BufferGetPage(buf);
+
+	_bt_pageinit(page, BufferGetPageSize(buf));
+	pageop = BTPageGetOpaque(page);
+
+	pageop->btpo_prev = xlrec->left_prev;
+	pageop->btpo_next = xlrec->left_next;
+	pageop->btpo_level = 0;
+	pageop->btpo_flags = BTP_HALF_DEAD | BTP_LEAF;
+	pageop->btpo_cycleid = 0;
+
+	MemSet(&trunctuple, 0, sizeof(IndexTupleData));
+	trunctuple.t_info = sizeof(IndexTupleData);
+	BTreeTupleSetTopParent(&trunctuple, InvalidBlockNumber);
+	if (PageAddItem(page, &trunctuple, sizeof(IndexTupleData), P_HIKEY, false, false) == InvalidOffsetNumber)
+		elog(ERROR, "could not add dummy high key to half-dead page");
+
+	PageSetLSN(page, lsn);
+
+	MarkBufferDirty(buf);
+	UnlockReleaseBuffer(buf);
+}
+
 void
 btree_redo(XLogReaderState *record)
 {
@@ -1059,6 +1269,31 @@ btree_redo(XLogReaderState *record)
 	MemoryContextReset(opCtx);
 }
 
+void
+btree2_redo(XLogReaderState *record)
+{
+	uint8		info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
+	MemoryContext oldCtx;
+
+	oldCtx = MemoryContextSwitchTo(opCtx);
+	switch (info)
+	{
+		case XLOG_BTREE2_MERGE:
+			btree_xlog_merge_page(record);
+			break;
+		case XLOG_BTREE2_CLEAR_MERGE_FLAG:
+			btree_xlog_clear_m_flag(record);
+			break;
+		case XLOG_BTREE2_MERGE_MARK_HALFDEAD:
+			btree_xlog_mark_ma_hd(record);
+			break;
+		default:
+			elog(PANIC, "btree2_redo: unknown op code %u", info);
+	}
+	MemoryContextSwitchTo(oldCtx);
+	MemoryContextReset(opCtx);
+}
+
 void
 btree_xlog_startup(void)
 {
@@ -1082,15 +1317,21 @@ btree_mask(char *pagedata, BlockNumber blkno)
 {
 	Page		page = (Page) pagedata;
 	BTPageOpaque maskopaq;
+	PageHeader	phdr = (PageHeader) page;
+	TransactionId saved_prune_xid;
 
-	mask_page_lsn_and_checksum(page);
+	saved_prune_xid = phdr->pd_prune_xid;
 
+	mask_page_lsn_and_checksum(page);
 	mask_page_hint_bits(page);
 	mask_unused_space(page);
 
 	maskopaq = BTPageGetOpaque(page);
 
-	if (P_ISLEAF(maskopaq))
+	if (P_ISMERGED(maskopaq))
+		phdr->pd_prune_xid = saved_prune_xid;
+
+	if (P_ISLEAF(maskopaq) && !P_ISMERGEDAWAY(maskopaq))
 	{
 		/*
 		 * In btree leaf pages, it is possible to modify the LP_FLAGS without
diff --git a/src/backend/access/rmgrdesc/nbtdesc.c b/src/backend/access/rmgrdesc/nbtdesc.c
index 1d08f9957bd..fe97899a230 100644
--- a/src/backend/access/rmgrdesc/nbtdesc.c
+++ b/src/backend/access/rmgrdesc/nbtdesc.c
@@ -132,6 +132,45 @@ btree_desc(StringInfo buf, XLogReaderState *record)
 								 xlrec->last_cleanup_num_delpages);
 				break;
 			}
+
+	}
+}
+
+void
+btree2_desc(StringInfo buf, XLogReaderState *record)
+{
+	char	   *rec = XLogRecGetData(record);
+	uint8		info = XLogRecGetInfo(record) & ~XLR_INFO_MASK;
+
+	switch (info)
+	{
+		case XLOG_BTREE2_MERGE:
+			{
+				xl_btree_merge *xlrec = (xl_btree_merge *) rec;
+
+				appendStringInfo(buf,
+								 "merge pages: left_prev: %u, left_next: %u, "
+								 "poffset: %u, safexid: %u:%u",
+								 xlrec->left_prev, xlrec->left_next,
+								 xlrec->poffset,
+								 EpochFromFullTransactionId(xlrec->safemergexid),
+								 XidFromFullTransactionId(xlrec->safemergexid));
+				break;
+			}
+		case XLOG_BTREE2_CLEAR_MERGE_FLAG:
+			{
+				appendStringInfoString(buf, "clear merge flag");
+				break;
+			}
+		case XLOG_BTREE2_MERGE_MARK_HALFDEAD:
+			{
+				xl_btree_mark_ma_hd *xlrec = (xl_btree_mark_ma_hd *) rec;
+
+				appendStringInfo(buf,
+								 "mark merged-away page half-dead: left: %u, right: %u",
+								 xlrec->left_prev, xlrec->left_next);
+				break;
+			}
 	}
 }
 
@@ -192,6 +231,26 @@ btree_identify(uint8 info)
 	return id;
 }
 
+const char *
+btree2_identify(uint8 info)
+{
+	const char *id = NULL;
+
+	switch (info & ~XLR_INFO_MASK)
+	{
+		case XLOG_BTREE2_MERGE:
+			id = "MERGE";
+			break;
+		case XLOG_BTREE2_CLEAR_MERGE_FLAG:
+			id = "CLEAR_MERGED_FLAG";
+			break;
+		case XLOG_BTREE2_MERGE_MARK_HALFDEAD:
+			id = "MARK_MA_AS_HD";
+			break;
+	}
+	return id;
+}
+
 static void
 delvacuum_desc(StringInfo buf, char *block_data,
 			   uint16 ndeleted, uint16 nupdated)
diff --git a/src/bin/pg_waldump/t/001_basic.pl b/src/bin/pg_waldump/t/001_basic.pl
index 7b33efc6299..006c17e6a8a 100644
--- a/src/bin/pg_waldump/t/001_basic.pl
+++ b/src/bin/pg_waldump/t/001_basic.pl
@@ -96,7 +96,8 @@ CommitTs
 ReplicationOrigin
 Generic
 LogicalMessage
-XLOG2$/,
+XLOG2
+Btree2$/,
 	'rmgr list');
 
 
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..f2cfe4b4548 100644
--- a/src/include/access/nbtree.h
+++ b/src/include/access/nbtree.h
@@ -83,6 +83,9 @@ typedef BTPageOpaqueData *BTPageOpaque;
 #define BTP_HAS_GARBAGE (1 << 6)	/* page has LP_DEAD tuples (deprecated) */
 #define BTP_INCOMPLETE_SPLIT (1 << 7)	/* right sibling's downlink is missing */
 #define BTP_HAS_FULLXID	(1 << 8)	/* contains BTDeletedPageData */
+#define BTP_MERGED		(1 << 9)	/* holds its left sibling's tuples */
+#define BTP_MERGED_AWAY	(1 << 10)	/* tombstone, tuples moved to right
+									 * sibling */
 
 /*
  * The max allowed value of a cycle ID is a bit less than 64K.  This is
@@ -227,6 +230,110 @@ typedef struct BTMetaPageData
 #define P_HAS_GARBAGE(opaque)	(((opaque)->btpo_flags & BTP_HAS_GARBAGE) != 0)
 #define P_INCOMPLETE_SPLIT(opaque)	(((opaque)->btpo_flags & BTP_INCOMPLETE_SPLIT) != 0)
 #define P_HAS_FULLXID(opaque)	(((opaque)->btpo_flags & BTP_HAS_FULLXID) != 0)
+#define P_ISMERGED(opaque)		(((opaque)->btpo_flags & BTP_MERGED) != 0)
+#define P_ISMERGEDAWAY(opaque)	(((opaque)->btpo_flags & BTP_MERGED_AWAY) != 0)
+
+/*
+ * Accessors for the MERGED_AWAY block number stored on BTP_MERGED pages.
+ *
+ * We store the block number of the corresponding BTP_MERGED_AWAY (tombstone)
+ * page in pd_prune_xid, which is a 4-byte field in PageHeaderData that is
+ * explicitly documented as "currently unused in index pages" (see bufpage.h).
+ * This is the only available 4-byte slot on a MERGED page that may be
+ * completely full of index tuples -- we cannot extend the special area on
+ * a full page, and the item content area is occupied by live tuples.
+ *
+ * Only valid when P_MERGED(opaque) is true.  All other B-tree pages keep
+ * pd_prune_xid at its default value of InvalidTransactionId (0).
+ */
+#define BTMergedPageGetMABlkno(page) \
+	((BlockNumber) ((PageHeader)(page))->pd_prune_xid)
+
+#define BTMergedPageSetMABlkno(page, blkno) \
+	(((PageHeader)(page))->pd_prune_xid = (TransactionId)(blkno))
+
+/*
+ * Clear the MA block number from a MERGED page when the BTP_MERGED flag is
+ * being cleared (e.g., during vacuum cleanup of the merge group).
+ * Restores pd_prune_xid to its standard value of InvalidTransactionId (0),
+ * since index pages normally never use that field.
+ */
+#define BTMergedPageClearMABlkno(page) \
+	(((PageHeader)(page))->pd_prune_xid = InvalidTransactionId)
+
+
+typedef struct BTMergedAwayPageData
+{
+	FullTransactionId safemergexid;
+	uint16		boundary_size;
+} BTMergedAwayPageData;
+
+static inline void
+BTPageSetMerged(Page page)
+{
+	BTPageOpaque opaque;
+
+	opaque = BTPageGetOpaque(page);
+	opaque->btpo_flags |= BTP_MERGED;
+}
+
+static inline void
+BTPageSetMergedAway(Page page, FullTransactionId safemergexid,
+					IndexTuple boundaryitup, Size boundarysz)
+{
+	BTPageOpaque opaque;
+	PageHeader	header;
+	BTMergedAwayPageData *contents;
+	char	   *dest;
+
+	opaque = BTPageGetOpaque(page);
+	header = ((PageHeader) page);
+
+	opaque->btpo_flags |= BTP_MERGED_AWAY | BTP_HAS_FULLXID;
+	header->pd_lower = MAXALIGN(SizeOfPageHeaderData) +
+		MAXALIGN(sizeof(BTMergedAwayPageData)) +
+		MAXALIGN(boundarysz);
+	header->pd_upper = header->pd_special;
+
+	contents = (BTMergedAwayPageData *) PageGetContents(page);
+	contents->safemergexid = safemergexid;
+	contents->boundary_size = boundarysz;
+
+	dest = ((char *) contents) + MAXALIGN(sizeof(BTMergedAwayPageData));
+	memcpy(dest, boundaryitup, boundarysz);
+}
+
+static inline FullTransactionId
+BTMergedAwayGetSafeXid(Page page)
+{
+	BTPageOpaque opaque;
+	BTMergedAwayPageData *contents;
+
+	opaque = BTPageGetOpaque(page);
+	Assert(P_ISMERGEDAWAY(opaque));
+
+	if (!P_HAS_FULLXID(opaque))
+		return FirstNormalFullTransactionId;
+
+	contents = (BTMergedAwayPageData *) PageGetContents(page);
+	return contents->safemergexid;
+}
+
+/*
+ * BTPageIsMergedMember -- true when a page is a valid member of the merge
+ * group whose tombstone (MA page) is at ma_blkno.
+ *
+ * Used during VACUUM cleanup to validate each M page before clearing its
+ * flags, preventing accidental absorption of pages from an adjacent group.
+ */
+static inline bool
+BTPageIsMergedMember(BTPageOpaque opq, Page pg, BlockNumber ma_blkno)
+{
+	return P_ISMERGED(opq) &&
+		P_ISLEAF(opq) &&
+		!P_ISMERGEDAWAY(opq) &&
+		BTMergedPageGetMABlkno(pg) == ma_blkno;
+}
 
 /*
  * BTDeletedPageData is the page contents of a deleted page
@@ -985,6 +1092,21 @@ typedef struct BTScanPosData
 	bool		moreLeft;
 	bool		moreRight;
 
+	/*
+	 * Set when _bt_readpage read a merged page with the merge filter on.
+	 * items[] then holds every matching tuple, and itemIndex starts at the
+	 * merge boundary.
+	 */
+	bool		mergeFiltered;
+
+	/*
+	 * Merge state of the scan at this page.  Kept here so mark/restore saves
+	 * and restores it together with the page.
+	 */
+	bool		needMergeRecovery;
+	bool		skipMergeRecovery;
+	BlockNumber mergedAwayBlkno;	/* blkno of L (BTP_MERGED_AWAY page) */
+
 	/*
 	 * The items array is always ordered in index order (ie, increasing
 	 * indexoffset).  When scanning backwards it is convenient to fill the
@@ -1092,6 +1214,18 @@ typedef struct BTScanOpaqueData
 	/* keep these last in struct for efficiency */
 	BTScanPosData currPos;		/* current position data */
 	BTScanPosData markPos;		/* marked position, if any */
+
+	/* Merge information */
+	BTScanInsert mergeBoundaryKey;	/* Pre-built insertion scan key */
+	IndexTuple	mergeBoundaryItup;
+	BlockNumber mergeBoundaryBlkno; /* L the boundary key was loaded from */
+
+	/*
+	 * Copy of the boundary taken with markPos, because the key above may
+	 * change before the mark is restored.
+	 */
+	IndexTuple	markBoundaryItup;
+
 } BTScanOpaqueData;
 
 typedef BTScanOpaqueData *BTScanOpaque;
@@ -1191,6 +1325,8 @@ extern void _bt_parallel_release(IndexScanDesc scan,
 extern void _bt_parallel_done(IndexScanDesc scan);
 extern void _bt_parallel_primscan_schedule(IndexScanDesc scan,
 										   BlockNumber curr_page);
+extern void _bt_parallel_set_skip_ma(IndexScanDesc scan, BlockNumber blkno);
+extern BlockNumber _bt_parallel_get_skip_ma(IndexScanDesc scan);
 
 /*
  * prototypes for functions in nbtdedup.c
@@ -1219,6 +1355,7 @@ extern void _bt_finish_split(Relation rel, Relation heaprel, Buffer lbuf,
 							 BTStack stack);
 extern Buffer _bt_getstackbuf(Relation rel, Relation heaprel, BTStack stack,
 							  BlockNumber child);
+extern void _bt_freestack(BTStack stack);
 
 /*
  * prototypes for functions in nbtsplitloc.c
@@ -1262,6 +1399,8 @@ extern void _bt_pagedel(Relation rel, Buffer leafbuf, BTVacState *vstate);
 extern void _bt_pendingfsm_init(Relation rel, BTVacState *vstate,
 								bool cleanuponly);
 extern void _bt_pendingfsm_finalize(Relation rel, BTVacState *vstate);
+extern bool _bt_pages_share_parent(Relation rel, BlockNumber left_blkno,
+								   BlockNumber right_blkno, BTScanInsert scankey, BTStack *stack_out);
 
 /*
  * prototypes for functions in nbtpreprocesskeys.c
@@ -1331,4 +1470,10 @@ extern IndexBuildResult *btbuild(Relation heap, Relation index,
 								 struct IndexInfo *indexInfo);
 extern void _bt_parallel_build_main(dsm_segment *seg, shm_toc *toc);
 
+/*
+ * prototypes for functions in nbtmerge.c
+ */
+extern int32 _bt_merge_index(Relation rel, float8 min_pct, float8 dest_pct, int32 num_pages);
+
+
 #endif							/* NBTREE_H */
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 3a78ec27fe8..89ef5305a96 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -43,6 +43,16 @@
 #define XLOG_BTREE_META_CLEANUP	0xE0	/* update cleanup-related data in the
 										 * metapage */
 
+
+
+/*
+ * We ran out of opcodes, so btree now has a second RmgrId.  These opcodes
+ * are associated with RM_BTREE2_ID.
+ */
+#define XLOG_BTREE2_MERGE					0x00
+#define XLOG_BTREE2_CLEAR_MERGE_FLAG			0x10
+#define XLOG_BTREE2_MERGE_MARK_HALFDEAD		0x20
+
 /*
  * All that we need to regenerate the meta-data page
  */
@@ -156,9 +166,10 @@ typedef struct xl_btree_split
 	OffsetNumber firstrightoff; /* first origpage item on rightpage */
 	OffsetNumber newitemoff;	/* new item's offset */
 	uint16		postingoff;		/* offset inside orig posting tuple */
+	BlockNumber merged_ma_blkno;
 } xl_btree_split;
 
-#define SizeOfBtreeSplit	(offsetof(xl_btree_split, postingoff) + sizeof(uint16))
+#define SizeOfBtreeSplit	(offsetof(xl_btree_split, merged_ma_blkno) + sizeof(BlockNumber))
 
 /*
  * When page is deduplicated, consecutive groups of tuples with equal keys are
@@ -170,11 +181,11 @@ typedef struct xl_btree_split
 typedef struct xl_btree_dedup
 {
 	uint16		nintervals;
-
+	BlockNumber merged_ma_blkno;
 	/* DEDUPLICATION INTERVALS FOLLOW */
 } xl_btree_dedup;
 
-#define SizeOfBtreeDedup 	(offsetof(xl_btree_dedup, nintervals) + sizeof(uint16))
+#define SizeOfBtreeDedup 	(offsetof(xl_btree_dedup, merged_ma_blkno) + sizeof(BlockNumber))
 
 /*
  * This is what we need to know about page reuse within btree.  This record
@@ -289,9 +300,11 @@ typedef struct xl_btree_mark_page_halfdead
 	BlockNumber leftblk;		/* leaf block's left sibling, if any */
 	BlockNumber rightblk;		/* leaf block's right sibling */
 	BlockNumber topparent;		/* topmost internal page in the subtree */
+	BlockNumber merged_ma_blkno;	/* leaf's MA block if leaf is M, else
+									 * InvalidBlockNumber */
 } xl_btree_mark_page_halfdead;
 
-#define SizeOfBtreeMarkPageHalfDead (offsetof(xl_btree_mark_page_halfdead, topparent) + sizeof(BlockNumber))
+#define SizeOfBtreeMarkPageHalfDead (offsetof(xl_btree_mark_page_halfdead, merged_ma_blkno) + sizeof(BlockNumber))
 
 /*
  * This is what we need to know about deletion of a btree page.  Note that we
@@ -324,11 +337,13 @@ typedef struct xl_btree_unlink_page
 	BlockNumber leafleftsib;
 	BlockNumber leafrightsib;
 	BlockNumber leaftopparent;	/* next child down in the subtree */
+	BlockNumber merged_ma_blkno;	/* leaf's MA block if leaf is M, else
+									 * InvalidBlockNumber */
 
 	/* xl_btree_metadata FOLLOWS IF XLOG_BTREE_UNLINK_PAGE_META */
 } xl_btree_unlink_page;
 
-#define SizeOfBtreeUnlinkPage	(offsetof(xl_btree_unlink_page, leaftopparent) + sizeof(BlockNumber))
+#define SizeOfBtreeUnlinkPage	(offsetof(xl_btree_unlink_page, merged_ma_blkno) + sizeof(BlockNumber))
 
 /*
  * New root log record.  There are zero tuples if this is to establish an
@@ -350,6 +365,34 @@ typedef struct xl_btree_newroot
 #define SizeOfBtreeNewroot	(offsetof(xl_btree_newroot, level) + sizeof(uint32))
 
 
+typedef struct xl_btree_merge
+{
+	BlockNumber left_prev;		/* L's left sibling */
+	BlockNumber left_next;		/* L's right sibling, R */
+	OffsetNumber poffset;		/* offset of L's downlink in parent */
+	FullTransactionId safemergexid;
+	/* block 0 (the tombstone) data: the boundary key */
+} xl_btree_merge;
+#define SizeOfBtreeMerge	(offsetof(xl_btree_merge, safemergexid) + sizeof(FullTransactionId))
+
+typedef struct xl_btree_clear_m
+{
+	bool		isCatalogRel;	/* to handle recovery conflict during logical
+								 * decoding on standby */
+	FullTransactionId safemergexid;
+} xl_btree_clear_m;
+#define SizeOfBtreeClearM 	(offsetof(xl_btree_clear_m, safemergexid) + sizeof(FullTransactionId))
+
+typedef struct xl_btree_mark_ma_hd
+{
+	BlockNumber left_prev;		/* tombstone's left sibling */
+	BlockNumber left_next;		/* tombstone's right sibling */
+	bool		isCatalogRel;	/* to handle recovery conflict during logical
+								 * decoding on standby */
+	FullTransactionId safemergexid;
+} xl_btree_mark_ma_hd;
+#define SizeOfBtreeMarkMaHd 	(offsetof(xl_btree_mark_ma_hd, safemergexid) + sizeof(FullTransactionId))
+
 /*
  * prototypes for functions in nbtxlog.c
  */
@@ -357,11 +400,14 @@ extern void btree_redo(XLogReaderState *record);
 extern void btree_xlog_startup(void);
 extern void btree_xlog_cleanup(void);
 extern void btree_mask(char *pagedata, BlockNumber blkno);
+extern void btree2_redo(XLogReaderState *record);
 
 /*
  * prototypes for functions in nbtdesc.c
  */
 extern void btree_desc(StringInfo buf, XLogReaderState *record);
 extern const char *btree_identify(uint8 info);
+extern void btree2_desc(StringInfo buf, XLogReaderState *record);
+extern const char *btree2_identify(uint8 info);
 
 #endif							/* NBTXLOG_H */
diff --git a/src/include/access/rmgrlist.h b/src/include/access/rmgrlist.h
index ae32ef16d67..9defafafdf7 100644
--- a/src/include/access/rmgrlist.h
+++ b/src/include/access/rmgrlist.h
@@ -48,3 +48,4 @@ PG_RMGR(RM_REPLORIGIN_ID, "ReplicationOrigin", replorigin_redo, replorigin_desc,
 PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, NULL, NULL, generic_mask, NULL)
 PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, logicalmsg_identify, NULL, NULL, NULL, logicalmsg_decode)
 PG_RMGR(RM_XLOG2_ID, "XLOG2", xlog2_redo, xlog2_desc, xlog2_identify, NULL, NULL, NULL, xlog2_decode)
+PG_RMGR(RM_BTREE2_ID, "Btree2", btree2_redo, btree2_desc, btree2_identify, NULL, NULL, btree_mask, NULL)
diff --git a/src/include/access/xlog_internal.h b/src/include/access/xlog_internal.h
index bf609c3c703..ed8f488dda5 100644
--- a/src/include/access/xlog_internal.h
+++ b/src/include/access/xlog_internal.h
@@ -32,7 +32,7 @@
 /*
  * Each page of XLOG file has a header like this:
  */
-#define XLOG_PAGE_MAGIC 0xD122	/* can be used as WAL version indicator */
+#define XLOG_PAGE_MAGIC 0xD123	/* can be used as WAL version indicator */
 
 typedef struct XLogPageHeaderData
 {
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index cfef572df80..17753f039b1 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -216,6 +216,8 @@ BTIndexStat
 BTInsertState
 BTInsertStateData
 BTLeader
+BTMergeState
+BTMergedAwayPageData
 BTMetaPageData
 BTOneVacInfo
 BTOptions
@@ -4377,6 +4379,8 @@ type
 tzEntry
 u_char
 u_int
+ua_merge_candidates
+ua_merge_detail
 ua_page_items
 ua_page_stats
 uchr
@@ -4455,10 +4459,13 @@ xl_brin_insert
 xl_brin_revmap_extend
 xl_brin_samepage_update
 xl_brin_update
+xl_btree_clear_m
 xl_btree_dedup
 xl_btree_delete
 xl_btree_insert
+xl_btree_mark_ma_hd
 xl_btree_mark_page_halfdead
+xl_btree_merge
 xl_btree_metadata
 xl_btree_newroot
 xl_btree_reuse_page
-- 
2.43.0

