From 57b71ec92b77e5e09d1f558630e5fdf233432576 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 2/3] pageinspect: add functions to find and merge B-tree
 leaf pages

Add bt_find_merge_candidates(), bt_merge_detail() and bt_merge() in a
new version 1.14.  bt_merge() changes the index; it is meant for
testing the merge code.
---
 contrib/pageinspect/Makefile                  |   2 +-
 contrib/pageinspect/btreefuncs.c              | 864 +++++++++++++++++-
 contrib/pageinspect/meson.build               |   1 +
 .../pageinspect/pageinspect--1.13--1.14.sql   |  66 ++
 contrib/pageinspect/pageinspect.control       |   2 +-
 doc/src/sgml/pageinspect.sgml                 | 104 +++
 6 files changed, 1031 insertions(+), 8 deletions(-)
 create mode 100644 contrib/pageinspect/pageinspect--1.13--1.14.sql

diff --git a/contrib/pageinspect/Makefile b/contrib/pageinspect/Makefile
index eae989569d0..09774fd340c 100644
--- a/contrib/pageinspect/Makefile
+++ b/contrib/pageinspect/Makefile
@@ -13,7 +13,7 @@ OBJS = \
 	rawpage.o
 
 EXTENSION = pageinspect
-DATA =  pageinspect--1.12--1.13.sql \
+DATA =  pageinspect--1.13--1.14.sql pageinspect--1.12--1.13.sql \
 	pageinspect--1.11--1.12.sql pageinspect--1.10--1.11.sql \
 	pageinspect--1.9--1.10.sql pageinspect--1.8--1.9.sql \
 	pageinspect--1.7--1.8.sql pageinspect--1.6--1.7.sql \
diff --git a/contrib/pageinspect/btreefuncs.c b/contrib/pageinspect/btreefuncs.c
index 9917663593b..05a8f17780a 100644
--- a/contrib/pageinspect/btreefuncs.c
+++ b/contrib/pageinspect/btreefuncs.c
@@ -36,10 +36,14 @@
 #include "funcapi.h"
 #include "miscadmin.h"
 #include "pageinspect.h"
+#include "storage/block.h"
+#include "storage/buf.h"
+#include "storage/bufmgr.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
 #include "utils/rel.h"
 #include "utils/varlena.h"
+#include "access/xlog.h"
 
 PG_FUNCTION_INFO_V1(bt_metap);
 PG_FUNCTION_INFO_V1(bt_page_items_1_9);
@@ -48,10 +52,18 @@ PG_FUNCTION_INFO_V1(bt_page_items_bytea);
 PG_FUNCTION_INFO_V1(bt_page_stats_1_9);
 PG_FUNCTION_INFO_V1(bt_page_stats);
 PG_FUNCTION_INFO_V1(bt_multi_page_stats);
+PG_FUNCTION_INFO_V1(bt_find_merge_candidates);
+PG_FUNCTION_INFO_V1(bt_merge_detail);
+PG_FUNCTION_INFO_V1(bt_merge);
 
 #define IS_INDEX(r) ((r)->rd_rel->relkind == RELKIND_INDEX)
 #define IS_BTREE(r) ((r)->rd_rel->relam == BTREE_AM_OID)
 
+#define BTREE_MERGE_THRESHOLD  0.20 /* page must be ≤20% full to be a
+									 * candidate */
+#define BTREE_TARGET_FILLFACTOR 0.90	/* merged result must stay ≤90% full */
+
+
 /* ------------------------------------------------
  * structure for single btree page statistics
  * ------------------------------------------------
@@ -98,6 +110,35 @@ typedef struct ua_page_items
 	TupleDesc	tupd;
 } ua_page_items;
 
+/*
+ * cross-call data structure for SRF for merge candidates
+ */
+typedef struct ua_merge_candidates
+{
+	Oid			relid;
+	BlockNumber current_blkno;
+	TupleDesc	tupd;
+	float8		merge_candidate_max_pct;
+	float8		merge_destination_max_pct;
+	int			num_pages;
+	int			returned;
+
+} ua_merge_candidates;
+
+/*
+ * State for bt_merge_detail(): holds the three block numbers of the
+ * Parent / Left / Right page trio across SRF calls.
+ */
+typedef struct ua_merge_detail
+{
+	Oid			relid;
+	BlockNumber parent_blkno;
+	BlockNumber left_blkno;
+	BlockNumber right_blkno;
+	int			call_cntr;		/* 0=parent, 1=left, 2=right */
+	bool		show_tids;
+	TupleDesc	tupd;
+} ua_merge_detail;
 
 /* -------------------------------------------------
  * GetBTPageStatistics()
@@ -156,6 +197,12 @@ GetBTPageStatistics(BlockNumber blkno, Buffer buffer, BTPageStat *stat)
 	}
 	else if (P_IGNORE(opaque))
 		stat->type = 'e';
+	else if (P_ISMERGEDAWAY(opaque))
+	{
+		stat->type = 'm';
+		/* Don't interpret BTMergedAwayPageData as index tuples */
+		maxoff = InvalidOffsetNumber;
+	}
 	else if (P_ISLEAF(opaque))
 		stat->type = 'l';
 	else if (P_ISROOT(opaque))
@@ -672,12 +719,18 @@ bt_page_items_internal(PG_FUNCTION_ARGS, enum pageinspect_version ext_version)
 
 		opaque = BTPageGetOpaque(uargs->page);
 
-		if (!P_ISDELETED(opaque))
+		if (!P_ISDELETED(opaque) && !P_ISMERGEDAWAY(opaque))
 			fctx->max_calls = PageGetMaxOffsetNumber(uargs->page);
 		else
 		{
-			/* Don't interpret BTDeletedPageData as index tuples */
-			elog(NOTICE, "page from block " INT64_FORMAT " is deleted", blkno);
+			/*
+			 * Don't interpret BTDeletedPageData or BTMergedAwayPageData as
+			 * index tuples
+			 */
+			if (P_ISDELETED(opaque))
+				elog(NOTICE, "page from block " INT64_FORMAT " is deleted", blkno);
+			else
+				elog(NOTICE, "page from block " INT64_FORMAT " is merged away", blkno);
 			fctx->max_calls = 0;
 		}
 		uargs->leafpage = P_ISLEAF(opaque);
@@ -787,13 +840,21 @@ bt_page_items_bytea(PG_FUNCTION_ARGS)
 
 		if (P_ISDELETED(opaque))
 			elog(NOTICE, "page is deleted");
+		else if (P_ISMERGEDAWAY(opaque))
+			elog(NOTICE, "page is merged away");
 
-		if (!P_ISDELETED(opaque))
+		if (!P_ISDELETED(opaque) && !P_ISMERGEDAWAY(opaque))
 			fctx->max_calls = PageGetMaxOffsetNumber(uargs->page);
 		else
 		{
-			/* Don't interpret BTDeletedPageData as index tuples */
-			elog(NOTICE, "page from block is deleted");
+			/*
+			 * Don't interpret BTDeletedPageData or BTMergedAwayPageData as
+			 * index tuples
+			 */
+			if (P_ISDELETED(opaque))
+				elog(NOTICE, "page from block is deleted");
+			else
+				elog(NOTICE, "page from block is merged away");
 			fctx->max_calls = 0;
 		}
 		uargs->leafpage = P_ISLEAF(opaque);
@@ -935,3 +996,794 @@ bt_metap(PG_FUNCTION_ARGS)
 
 	PG_RETURN_DATUM(result);
 }
+
+
+/*
+ * Check the arguments shared by bt_find_merge_candidates() and bt_merge().
+ */
+static void
+check_merge_args(float8 candidate_max_pct, float8 destination_max_pct,
+				 int32 num_pages)
+{
+	/* written this way so that NaN is rejected too */
+	if (!(candidate_max_pct >= 0 && candidate_max_pct <= 100))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("merge_candidate_max_pct must be between 0 and 100")));
+	if (!(destination_max_pct >= 0 && destination_max_pct <= 100))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("merge_destination_max_pct must be between 0 and 100")));
+	if (num_pages < 1)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("num_pages must be at least 1")));
+}
+
+/*-------------------------------------------------------
+ * bt_find_merge_candidates()
+ *
+ * Scan the B-tree leaf chain left-to-right and return up to num_pages
+ * adjacent leaf pairs where both pages are at most merge_candidate_max_pct
+ * percent full and their combined content is at most
+ * merge_destination_max_pct percent of a page.  Only merge candidates are
+ * returned.
+ *
+ * Usage: SELECT * FROM bt_find_merge_candidates('t1_pkey', 10.0, 90.0, 5);
+ *-------------------------------------------------------
+ */
+Datum
+bt_find_merge_candidates(PG_FUNCTION_ARGS)
+{
+	text	   *relname = PG_GETARG_TEXT_PP(0);
+	float8		merge_candidate_max_pct = PG_GETARG_FLOAT8(1);
+	float8		merge_destination_max_pct = PG_GETARG_FLOAT8(2);
+	int32		num_pages = PG_GETARG_INT32(3);
+	Datum		result;
+	FuncCallContext *fctx;
+	MemoryContext mctx;
+	ua_merge_candidates *uargs;
+	Buffer		leftbuf,
+				rightbuf;
+	Page		leftpage,
+				rightpage;
+	BTPageOpaque leftopaque,
+				rightopaque;
+	BlockNumber left_blkno,
+				right_blkno;
+	Size		left_free,
+				right_free;
+	Size		left_used,
+				right_used;
+	BTScanInsert scankey;
+	IndexTuple	keytuple = NULL;
+	int			j;
+	Datum		values[5];
+	bool		nulls[5];
+	HeapTuple	tuple;
+	bool		heapkeyspace;
+	bool		allequalimage;
+
+	if (!superuser())
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("must be superuser to use pageinspect functions")));
+
+	check_merge_args(merge_candidate_max_pct, merge_destination_max_pct,
+					 num_pages);
+
+	if (SRF_IS_FIRSTCALL())
+	{
+		RangeVar   *relrv;
+		Relation	rel;
+		TupleDesc	tupleDesc;
+
+		fctx = SRF_FIRSTCALL_INIT();
+
+		relrv = makeRangeVarFromNameList(textToQualifiedNameList(relname));
+		rel = relation_openrv(relrv, AccessShareLock);
+
+		if (!IS_INDEX(rel) || !IS_BTREE(rel))
+			ereport(ERROR, (errcode(ERRCODE_WRONG_OBJECT_TYPE),
+							errmsg("\"%s\" is not a %s index",
+								   RelationGetRelationName(rel), "btree")));
+
+		_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.")));
+
+		mctx = MemoryContextSwitchTo(fctx->multi_call_memory_ctx);
+		uargs = palloc_object(ua_merge_candidates);
+
+		/*
+		 * Start at the second leftmost leaf page.
+		 */
+		{
+			Buffer		endpoint_buf = _bt_get_endpoint(rel, 0, false);
+
+			if (BufferIsValid(endpoint_buf))
+			{
+				Page		temp_page = BufferGetPage(endpoint_buf);
+				BTPageOpaque temp_opaque = BTPageGetOpaque(temp_page);
+
+				uargs->current_blkno = temp_opaque->btpo_next;
+				UnlockReleaseBuffer(endpoint_buf);
+			}
+			else
+				uargs->current_blkno = P_NONE;
+		}
+
+		uargs->relid = RelationGetRelid(rel);
+		relation_close(rel, AccessShareLock);
+
+		uargs->merge_candidate_max_pct = merge_candidate_max_pct / 100.0;
+		uargs->merge_destination_max_pct = merge_destination_max_pct / 100.0;
+		uargs->num_pages = num_pages;
+		uargs->returned = 0;
+
+		/* Build a tuple descriptor for our result type */
+		if (get_call_result_type(fcinfo, NULL, &tupleDesc) != TYPEFUNC_COMPOSITE)
+			elog(ERROR, "return type must be a row type");
+		tupleDesc = BlessTupleDesc(tupleDesc);
+
+		uargs->tupd = tupleDesc;
+		fctx->user_fctx = uargs;
+
+		MemoryContextSwitchTo(mctx);
+	}
+
+	fctx = SRF_PERCALL_SETUP();
+	uargs = fctx->user_fctx;
+
+	if (uargs->returned >= uargs->num_pages)
+		SRF_RETURN_DONE(fctx);
+
+	/*
+	 * Reopen the relation for this call.  We open and close it every call so
+	 * that a LIMIT early exit cannot leak a relcache reference.
+	 */
+	{
+		Relation	rel = relation_open(uargs->relid, AccessShareLock);
+
+		/*
+		 * Walk the leaf chain looking for the next merge candidate. We skip
+		 * non-candidates and stop at the rightmost leaf or end of chain.
+		 */
+		for (;;)
+		{
+			CHECK_FOR_INTERRUPTS();
+
+			if (uargs->current_blkno == P_NONE)
+			{
+				relation_close(rel, AccessShareLock);
+				SRF_RETURN_DONE(fctx);
+			}
+
+			left_blkno = uargs->current_blkno;
+			leftbuf = ReadBuffer(rel, left_blkno);
+			LockBuffer(leftbuf, BUFFER_LOCK_SHARE);
+			leftpage = BufferGetPage(leftbuf);
+			leftopaque = BTPageGetOpaque(leftpage);
+
+			if (P_RIGHTMOST(leftopaque))
+			{
+				UnlockReleaseBuffer(leftbuf);
+				relation_close(rel, AccessShareLock);
+				SRF_RETURN_DONE(fctx);
+			}
+
+			/* Skip deleted, half-dead, and already-merged pages. */
+			if (P_ISDELETED(leftopaque) || P_ISHALFDEAD(leftopaque)
+				|| P_ISMERGED(leftopaque) || P_ISMERGEDAWAY(leftopaque))
+			{
+				uargs->current_blkno = leftopaque->btpo_next;
+				UnlockReleaseBuffer(leftbuf);
+				continue;
+			}
+
+			Assert(P_ISLEAF(leftopaque));
+
+			/* Lock couple: acquire right BEFORE releasing left */
+			right_blkno = leftopaque->btpo_next;
+			left_free = PageGetFreeSpace(leftpage);
+			rightbuf = ReadBuffer(rel, right_blkno);
+			LockBuffer(rightbuf, BUFFER_LOCK_SHARE);
+			rightpage = BufferGetPage(rightbuf);
+			rightopaque = BTPageGetOpaque(rightpage);
+			right_free = PageGetFreeSpace(rightpage);
+
+			/* R is unusable; skip directly past it using its btpo_next. */
+			if (P_ISDELETED(rightopaque) || P_ISHALFDEAD(rightopaque)
+				|| P_ISMERGED(rightopaque) || P_ISMERGEDAWAY(rightopaque))
+			{
+				uargs->current_blkno = rightopaque->btpo_next;
+				UnlockReleaseBuffer(rightbuf);
+				UnlockReleaseBuffer(leftbuf);
+				continue;
+			}
+
+			Assert(P_ISLEAF(rightopaque));
+
+			scankey = NULL;
+			{
+				OffsetNumber first_data_off = P_FIRSTDATAKEY(leftopaque);
+
+				if (first_data_off <= PageGetMaxOffsetNumber(leftpage))
+				{
+					IndexTuple	first_itup = (IndexTuple) PageGetItem(leftpage,
+																	  PageGetItemId(leftpage, first_data_off));
+
+					/*
+					 * The key must not point into L's buffer, which is
+					 * released below
+					 */
+					keytuple = CopyIndexTuple(first_itup);
+					scankey = _bt_mkscankey(rel, keytuple);
+				}
+			}
+
+			/* L is examined; slide the window to R as the default next-left. */
+			uargs->current_blkno = right_blkno;
+
+			UnlockReleaseBuffer(rightbuf);
+			UnlockReleaseBuffer(leftbuf);
+
+			left_used = BLCKSZ - left_free;
+			right_used = BLCKSZ - right_free;
+
+			/*
+			 * Size check: both pages must individually be below the threshold
+			 * AND their combined content must fit within the target
+			 * fillfactor.
+			 */
+			if ((float) left_used / BLCKSZ <= uargs->merge_candidate_max_pct &&
+				(float) right_used / BLCKSZ <= uargs->merge_candidate_max_pct &&
+				(float) (left_used + right_used) / BLCKSZ <= uargs->merge_destination_max_pct)
+			{
+				/*
+				 * Parent check: left and right must share the same immediate
+				 * parent with adjacent downlinks.
+				 */
+				if (scankey != NULL &&
+
+					_bt_pages_share_parent(rel, left_blkno, right_blkno, scankey, NULL))
+				{
+					pfree(scankey);
+					pfree(keytuple);
+					relation_close(rel, AccessShareLock);
+					break;		/* found a valid candidate */
+				}
+			}
+
+			if (scankey)
+			{
+				pfree(scankey);
+				pfree(keytuple);
+			}
+		}
+	}
+
+
+	/* Build and return the output row */
+	uargs->returned++;
+	memset(nulls, false, sizeof(nulls));
+	j = 0;
+	values[j++] = Int64GetDatum((int64) left_blkno);
+	values[j++] = Int64GetDatum((int64) right_blkno);
+	values[j++] = Float8GetDatum(100.0 * left_free / BLCKSZ);
+	values[j++] = Float8GetDatum(100.0 * right_free / BLCKSZ);
+	/* free space the merged page would have */
+	values[j++] = Float8GetDatum(
+								 100.0 * ((int64) left_free + (int64) right_free - (int64) BLCKSZ) / BLCKSZ);
+
+	tuple = heap_form_tuple(uargs->tupd, values, nulls);
+	result = HeapTupleGetDatum(tuple);
+	SRF_RETURN_NEXT(fctx, result);
+}
+
+static char *
+index_tuple_data(IndexTuple itup)
+{
+	char	   *ptr;
+	int			dlen;
+	char	   *dump;
+	char	   *datacstring;
+
+	ptr = (char *) itup + IndexInfoFindDataOffset(itup->t_info);
+	dlen = IndexTupleSize(itup) - IndexInfoFindDataOffset(itup->t_info);
+
+	if (BTreeTupleIsPosting(itup))
+		dlen -= IndexTupleSize(itup) - BTreeTupleGetPostingOffset(itup);
+	else if (BTreeTupleIsPivot(itup) && BTreeTupleGetHeapTID(itup) != NULL)
+		dlen -= MAXALIGN(sizeof(ItemPointerData));
+
+	if (dlen < 0 || dlen > INDEX_SIZE_MASK)
+		elog(ERROR, "invalid tuple length %d", dlen);
+
+	dump = palloc0(dlen * 3 + 1);
+	datacstring = dump;
+	for (int off = 0; off < dlen; off++)
+	{
+		if (off > 0)
+			*dump++ = ' ';
+		sprintf(dump, "%02x", *(ptr + off) & 0xff);
+		dump += 2;
+	}
+
+	return datacstring;
+}
+
+/*-------------------------------------------------------
+ * bt_merge_detail()
+ *
+ * Given a LEFT leaf block number, find its Right sibling and Parent page
+ * using a proper O(log N) B-tree descent, then return one row of structural
+ * details for each of the three pages in this order: Parent, Left, Right.
+ *
+ * Each row contains the page header fields and a tid[] array.  For leaf
+ * pages the array holds heap TIDs (posting-list entries are expanded).
+ * For the parent page the array holds the child downlinks encoded as TIDs.
+ *
+ * Usage: SELECT * FROM bt_merge_detail('t1_pkey', 5, false);
+ *-------------------------------------------------------
+ */
+Datum
+bt_merge_detail(PG_FUNCTION_ARGS)
+{
+	FuncCallContext *fctx;
+	ua_merge_detail *uargs;
+
+	if (SRF_IS_FIRSTCALL())
+	{
+		text	   *relname = PG_GETARG_TEXT_PP(0);
+		int64		left_blkno_arg = PG_GETARG_INT64(1);
+		MemoryContext mctx;
+		TupleDesc	tupleDesc;
+		Relation	rel;
+		List	   *relname_list;
+		RangeVar   *relrv;
+		Buffer		leftbuf;
+		Page		leftpage;
+		BTPageOpaque leftopaque;
+		OffsetNumber first_data_off;
+		IndexTuple	first_itup;
+		BTScanInsert scankey;
+		Buffer		found_buf = InvalidBuffer;
+		BTStack		stack;
+		BlockNumber parent_blkno = InvalidBlockNumber;
+		BlockNumber left_blkno;
+		BlockNumber right_blkno;
+
+		fctx = SRF_FIRSTCALL_INIT();
+		mctx = MemoryContextSwitchTo(fctx->multi_call_memory_ctx);
+
+		uargs = palloc_object(ua_merge_detail);
+
+		if (!superuser())
+			ereport(ERROR,
+					(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+					 errmsg("must be superuser to use pageinspect functions")));
+
+		/* Open and validate the relation */
+		relname_list = textToQualifiedNameList(relname);
+		relrv = makeRangeVarFromNameList(relname_list);
+		rel = relation_openrv(relrv, AccessShareLock);
+
+		/* Validate that it is a B-Tree and the block number is within bounds */
+		bt_index_block_validate(rel, left_blkno_arg);
+
+		left_blkno = (BlockNumber) left_blkno_arg;
+
+		/*
+		 * Step 1: Read the left leaf page to get right_blkno and the first
+		 * data tuple, which we will use to build the scan key for the
+		 * descent.
+		 */
+		leftbuf = ReadBuffer(rel, left_blkno);
+		LockBuffer(leftbuf, BUFFER_LOCK_SHARE);
+		leftpage = BufferGetPage(leftbuf);
+		leftopaque = BTPageGetOpaque(leftpage);
+
+		if (!P_ISLEAF(leftopaque))
+			ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+							errmsg("block %u is not a leaf page", left_blkno)));
+
+		if (P_ISDELETED(leftopaque) || P_ISHALFDEAD(leftopaque))
+			ereport(ERROR, (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+							errmsg("block %u is a deleted or half-dead page and cannot be inspected for merge", left_blkno)));
+
+		if (P_ISMERGEDAWAY(leftopaque))
+			ereport(ERROR, (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+							errmsg("block %u is a merged-away tombstone and cannot be inspected for merge", left_blkno)));
+
+		right_blkno = leftopaque->btpo_next;
+
+		if (P_RIGHTMOST(leftopaque))
+			ereport(
+					ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("block %u is the rightmost leaf page, so it has no right sibling",
+							left_blkno)));
+
+		/*
+		 * P_FIRSTDATAKEY returns the offset of the first real data tuple,
+		 * skipping the high-key slot on non-leftmost pages.
+		 */
+		first_data_off = P_FIRSTDATAKEY(leftopaque);
+		if (first_data_off > PageGetMaxOffsetNumber(leftpage))
+			ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+							errmsg("leaf page %u is empty, so its parent cannot be found",
+								   left_blkno)));
+
+		/* Copy it: the key must not point into L's buffer after the release */
+		first_itup = CopyIndexTuple((IndexTuple) PageGetItem(
+															 leftpage, PageGetItemId(leftpage, first_data_off)));
+
+		/*
+		 * Build a typed scan key from the tuple using the relation schema.
+		 * _bt_mkscankey reads the TupleDesc and operator classes from rel, so
+		 * our extension does not need to know the column data types.
+		 */
+		scankey = _bt_mkscankey(rel, first_itup);
+
+		UnlockReleaseBuffer(leftbuf);	/* release left before the descent */
+
+		/*
+		 * Step 2: Descend from the root in O(log N) reads. _bt_search
+		 * returns: - found_buf: the leaf buffer (locked); we release it
+		 * immediately. - stack: linked list from root to parent;
+		 * stack->bts_blkno is the block number of the immediate parent
+		 * (level-1 page).
+		 */
+		stack = _bt_search(rel, NULL, scankey, &found_buf, BT_READ, true);
+		if (stack != NULL)
+			parent_blkno = stack->bts_blkno;
+
+		if (BufferIsValid(found_buf))
+			UnlockReleaseBuffer(found_buf);
+		if (stack != NULL)
+			_bt_freestack(stack);
+		pfree(scankey);
+		pfree(first_itup);
+
+		if (parent_blkno == InvalidBlockNumber)
+			ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR),
+							errmsg("could not find parent page for leaf block %u",
+								   left_blkno)));
+
+		/*
+		 * Step 3: Verify that Right is also a child of the same parent.
+		 *
+		 * Adjacent leaf siblings do not always share the same parent.  They
+		 * can straddle a parent-page boundary after a split.  Instead of a
+		 * second O(log N) descent, we simply scan the parent page we already
+		 * have: if right_blkno does not appear as a downlink there, the two
+		 * leaves have different parents and cannot be merged with a single
+		 * parent update.
+		 */
+		{
+			Buffer		parentbuf;
+			Page		parentpage;
+			OffsetNumber off,
+						maxoff;
+			bool		found_right = false;
+
+			parentbuf = ReadBuffer(rel, parent_blkno);
+			LockBuffer(parentbuf, BUFFER_LOCK_SHARE);
+			parentpage = BufferGetPage(parentbuf);
+			maxoff = PageGetMaxOffsetNumber(parentpage);
+
+			/*
+			 * On a page that is not rightmost, offset 1 is the high key, not
+			 * a downlink.  Start after it to scan only downlinks.
+			 */
+			{
+				BTPageOpaque pOpaque = BTPageGetOpaque(parentpage);
+				OffsetNumber scan_start = P_RIGHTMOST(pOpaque)
+					? FirstOffsetNumber
+					: OffsetNumberNext(P_HIKEY);
+
+				for (off = scan_start; off <= maxoff; off++)
+				{
+					IndexTuple	itup = (IndexTuple) PageGetItem(
+																parentpage, PageGetItemId(parentpage, off));
+
+					if (ItemPointerGetBlockNumberNoCheck(&itup->t_tid) == right_blkno)
+					{
+						found_right = true;
+						break;
+					}
+				}
+			}
+
+			UnlockReleaseBuffer(parentbuf);
+
+			if (!found_right)
+			{
+				ereport(ERROR,
+						(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						 errmsg("pages cannot be merged: left block %u and right block "
+								"%u span a parent boundary",
+								left_blkno, right_blkno),
+						 errhint("Parent block %u does not contain the right block.",
+								 parent_blkno)));
+			}
+		}
+
+		/*
+		 * Store only the relation Oid so we can safely reopen it on every
+		 * per-call invocation without leaking a relcache reference on LIMIT.
+		 */
+		uargs->relid = RelationGetRelid(rel);
+		relation_close(rel, AccessShareLock);
+
+		uargs->parent_blkno = parent_blkno;
+		uargs->left_blkno = left_blkno;
+		uargs->right_blkno = right_blkno;
+		uargs->call_cntr = 0;
+		uargs->show_tids = PG_GETARG_BOOL(2);
+
+		if (get_call_result_type(fcinfo, NULL, &tupleDesc) != TYPEFUNC_COMPOSITE)
+			elog(ERROR, "return type must be a row type");
+		tupleDesc = BlessTupleDesc(tupleDesc);
+
+		uargs->tupd = tupleDesc;
+		fctx->user_fctx = uargs;
+
+		MemoryContextSwitchTo(mctx);
+	}
+
+	fctx = SRF_PERCALL_SETUP();
+	uargs = fctx->user_fctx;
+
+	/* We return exactly 3 rows: 0=parent, 1=left, 2=right */
+	if (uargs->call_cntr >= 3)
+		SRF_RETURN_DONE(fctx);
+
+	{
+		Relation	rel = relation_open(uargs->relid, AccessShareLock);
+		BlockNumber blkno;
+		BlockNumber btpo_prev_val;
+		BlockNumber btpo_next_val;
+		uint32		btpo_flags_val;
+		uint32		btpo_level_val;
+		Size		free_size_val;
+		const char *role;
+		Buffer		buf;
+		Page		page;
+		BTPageOpaque opaque;
+		OffsetNumber off,
+					maxoff,
+					first_off;
+		char	   *high_key_hex = NULL;
+		char	   *first_val_hex = NULL;
+		char	   *last_val_hex = NULL;
+		int			max_tids;
+		ItemPointerData *tids_buf;
+		int			ntids = 0;
+		Datum	   *tids_datum;
+		Datum		values[13];
+		bool		nulls[13];
+		int			j;
+		HeapTuple	tuple;
+		Datum		result;
+
+		switch (uargs->call_cntr)
+		{
+			case 0:
+				blkno = uargs->parent_blkno;
+				role = "parent";
+				break;
+			case 1:
+				blkno = uargs->left_blkno;
+				role = "left";
+				break;
+			default:
+				blkno = uargs->right_blkno;
+				role = "right";
+				break;
+		}
+
+		buf = ReadBuffer(rel, blkno);
+		LockBuffer(buf, BUFFER_LOCK_SHARE);
+		page = BufferGetPage(buf);
+		opaque = BTPageGetOpaque(page);
+		maxoff = PageGetMaxOffsetNumber(page);
+
+		btpo_prev_val = opaque->btpo_prev;
+		btpo_next_val = opaque->btpo_next;
+		btpo_flags_val = opaque->btpo_flags;
+		btpo_level_val = opaque->btpo_level;
+		free_size_val = PageGetFreeSpace(page);
+
+		/*
+		 * Collect TIDs / downlinks from the page.
+		 *
+		 * Leaf pages: iterate data tuples (skip high key via P_FIRSTDATAKEY).
+		 * - Posting-list tuples: expand with BTreeTupleGetPosting(). - Normal
+		 * tuples: take t_tid directly.
+		 *
+		 * Parent page: all items are pivot tuples; the block-number part of
+		 * t_tid is the child downlink.  Skip minus-infinity entries that have
+		 * no real downlink (InvalidBlockNumber).
+		 *
+		 * Upper bound: 2*BLCKSZ/sizeof(ItemPointerData) covers any real page.
+		 */
+		max_tids = 2 * BLCKSZ / sizeof(ItemPointerData);
+		tids_buf = (ItemPointerData *) palloc(max_tids * sizeof(ItemPointerData));
+
+		if (P_ISLEAF(opaque) && !P_ISMERGEDAWAY(opaque) && !P_ISDELETED(opaque))
+		{
+			first_off = P_FIRSTDATAKEY(opaque);
+			for (off = first_off; off <= maxoff; off++)
+			{
+				IndexTuple	itup =
+					(IndexTuple) PageGetItem(page, PageGetItemId(page, off));
+
+				if (BTreeTupleIsPosting(itup))
+				{
+					int			nposting = BTreeTupleGetNPosting(itup);
+					ItemPointer posting = BTreeTupleGetPosting(itup);
+
+					for (int i = 0; i < nposting && ntids < max_tids; i++)
+						tids_buf[ntids++] = posting[i];
+				}
+				else
+				{
+					if (ntids < max_tids)
+						tids_buf[ntids++] = itup->t_tid;
+				}
+			}
+
+			/*
+			 * High key
+			 */
+			if (!P_RIGHTMOST(opaque))
+			{
+				IndexTuple	hikey =
+					(IndexTuple) PageGetItem(page, PageGetItemId(page, P_HIKEY));
+
+				high_key_hex = index_tuple_data(hikey);
+			}
+
+			/*
+			 * First and last data tuples
+			 */
+			if (first_off <= maxoff)
+			{
+				IndexTuple	first_itup =
+					(IndexTuple) PageGetItem(page, PageGetItemId(page, first_off));
+				IndexTuple	last_itup =
+					(IndexTuple) PageGetItem(page, PageGetItemId(page, maxoff));
+
+				first_val_hex = index_tuple_data(first_itup);
+				last_val_hex = index_tuple_data(last_itup);
+			}
+		}
+		else if (!P_ISMERGEDAWAY(opaque) && !P_ISDELETED(opaque))
+		{
+			for (off = FirstOffsetNumber; off <= maxoff; off++)
+			{
+				IndexTuple	itup =
+					(IndexTuple) PageGetItem(page, PageGetItemId(page, off));
+
+				if (BTreeTupleIsPivot(itup) &&
+					ItemPointerGetBlockNumberNoCheck(&itup->t_tid) !=
+					InvalidBlockNumber)
+				{
+					if (ntids < max_tids)
+						tids_buf[ntids++] = itup->t_tid;
+				}
+			}
+		}
+
+		UnlockReleaseBuffer(buf);
+		relation_close(rel, AccessShareLock);
+
+		/* Convert tids_buf into a Datum array for construct_array_builtin */
+		tids_datum = (Datum *) palloc(ntids * sizeof(Datum));
+		for (int i = 0; i < ntids; i++)
+			tids_datum[i] = ItemPointerGetDatum(&tids_buf[i]);
+
+		/* Build output tuple */
+		memset(nulls, false, sizeof(nulls));
+		j = 0;
+		values[j++] = CStringGetTextDatum(role);
+		values[j++] = Int64GetDatum((int64) blkno);
+		values[j++] = Int64GetDatum((int64) btpo_prev_val);
+		values[j++] = Int64GetDatum((int64) btpo_next_val);
+		values[j++] = Int64GetDatum((int64) btpo_level_val);
+		values[j++] = Int32GetDatum((int32) btpo_flags_val);
+		values[j++] = Int32GetDatum((int32) free_size_val);
+		values[j++] = Float8GetDatum(100.0 * free_size_val / BLCKSZ);
+		values[j++] = Int32GetDatum((int32) ntids);
+		if (high_key_hex)
+			values[j++] = CStringGetTextDatum(high_key_hex);
+		else
+			nulls[j++] = true;
+		if (first_val_hex)
+			values[j++] = CStringGetTextDatum(first_val_hex);
+		else
+			nulls[j++] = true;
+		if (last_val_hex)
+			values[j++] = CStringGetTextDatum(last_val_hex);
+		else
+			nulls[j++] = true;
+		if (uargs->show_tids)
+			values[j++] = PointerGetDatum(construct_array_builtin(tids_datum, ntids, TIDOID));
+		else
+			nulls[j++] = true;
+
+		tuple = heap_form_tuple(uargs->tupd, values, nulls);
+		result = HeapTupleGetDatum(tuple);
+
+		uargs->call_cntr++;
+		SRF_RETURN_NEXT(fctx, result);
+	}
+}
+
+/*
+ * bt_merge(relname, merge_candidate_max_pct, merge_destination_max_pct,
+ *          num_pages)
+ *
+ * Merge nearly empty leaf pages of a B-tree index into their right sibling.
+ * This changes the index.  Returns the number of merges done.
+ */
+Datum
+bt_merge(PG_FUNCTION_ARGS)
+{
+	text	   *relname = PG_GETARG_TEXT_PP(0);
+	float8		merge_candidate_max_pct = PG_GETARG_FLOAT8(1);
+	float8		merge_destination_max_pct = PG_GETARG_FLOAT8(2);
+	int32		num_pages = PG_GETARG_INT32(3);
+	Relation	rel;
+	RangeVar   *relrv;
+	int32		merges_performed;
+
+
+	if (!superuser())
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("must be superuser to use pageinspect functions")));
+
+	check_merge_args(merge_candidate_max_pct, merge_destination_max_pct,
+					 num_pages);
+
+	if (RecoveryInProgress())
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("recovery is in progress"),
+				 errhint("bt_merge() cannot be executed during recovery.")));
+
+	relrv = makeRangeVarFromNameList(textToQualifiedNameList(relname));
+	rel = relation_openrv(relrv, RowExclusiveLock);
+
+	if (!IS_INDEX(rel) || !IS_BTREE(rel))
+		ereport(ERROR,
+				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				 errmsg("\"%s\" is not a %s index",
+						RelationGetRelationName(rel), "btree")));
+
+	/*
+	 * Reject attempts to read non-local temporary relations; we would be
+	 * likely to get wrong data since we have no visibility into the owning
+	 * session's local buffers.
+	 */
+	if (RELATION_IS_OTHER_TEMP(rel))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("cannot access temporary tables of other sessions")));
+
+
+	merges_performed = _bt_merge_index(rel, merge_candidate_max_pct, merge_destination_max_pct, num_pages);
+
+	relation_close(rel, NoLock);
+
+
+	PG_RETURN_INT32(merges_performed);
+
+}
diff --git a/contrib/pageinspect/meson.build b/contrib/pageinspect/meson.build
index c43ea400a4d..2f333635838 100644
--- a/contrib/pageinspect/meson.build
+++ b/contrib/pageinspect/meson.build
@@ -38,6 +38,7 @@ install_data(
   'pageinspect--1.10--1.11.sql',
   'pageinspect--1.11--1.12.sql',
   'pageinspect--1.12--1.13.sql',
+  'pageinspect--1.13--1.14.sql',
   'pageinspect.control',
   kwargs: contrib_data_args,
 )
diff --git a/contrib/pageinspect/pageinspect--1.13--1.14.sql b/contrib/pageinspect/pageinspect--1.13--1.14.sql
new file mode 100644
index 00000000000..46672435dab
--- /dev/null
+++ b/contrib/pageinspect/pageinspect--1.13--1.14.sql
@@ -0,0 +1,66 @@
+/* contrib/pageinspect/pageinspect--1.13--1.14.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION pageinspect UPDATE TO '1.14'" to load this file. \quit
+
+--
+-- bt_find_merge_candidates(relname, merge_candidate_max_pct,
+--                          merge_destination_max_pct, num_pages)
+--
+-- Scan the B-tree leaf chain and return up to num_pages adjacent pairs that
+-- are merge candidates (both pages <= merge_candidate_max_pct percent full,
+-- and together <= merge_destination_max_pct percent of a page).
+--
+CREATE FUNCTION bt_find_merge_candidates(
+    IN  relname           text,
+    IN  merge_candidate_max_pct float8 DEFAULT 10.0,
+    IN  merge_destination_max_pct float8 DEFAULT 90.0,
+    IN  num_pages         int4   DEFAULT 1,
+    OUT left_blkno        int8,
+    OUT right_blkno       int8,
+    OUT free_space_left   float8,
+    OUT free_space_right  float8,
+    OUT result_free_space  float8)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'bt_find_merge_candidates'
+LANGUAGE C PARALLEL RESTRICTED;
+
+--
+-- bt_merge_detail(relname, left_blkno)
+--
+-- Given the LEFT leaf block number, find its Right sibling and Parent via
+-- a proper B-tree descent (O(log N)), then return one detail row for each
+-- of the three pages: Parent, Left, Right.
+--
+CREATE FUNCTION bt_merge_detail(
+    IN  relname     text,
+    IN  left_blkno  int8,
+    IN  show_tids   boolean,
+    OUT page_role   text,
+    OUT blkno       int8,
+    OUT btpo_prev   int8,
+    OUT btpo_next   int8,
+    OUT btpo_level  int8,
+    OUT btpo_flags  int4,
+    OUT free_size   int4,
+    OUT pct_free    float8,
+    OUT num_tids    int4,
+    OUT high_key    text,
+    OUT first_val   text,
+    OUT last_val    text,
+    OUT tids        tid[]
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'bt_merge_detail'
+LANGUAGE C PARALLEL RESTRICTED;
+
+CREATE FUNCTION bt_merge(
+    IN  relname                   text,
+    IN  merge_candidate_max_pct   float8 DEFAULT 10.0,
+    IN  merge_destination_max_pct float8 DEFAULT 90.0,
+    IN  num_pages                 int4   DEFAULT 1,
+    OUT merges_performed          int4
+)
+RETURNS int4
+AS 'MODULE_PATHNAME', 'bt_merge'
+LANGUAGE C PARALLEL UNSAFE;
diff --git a/contrib/pageinspect/pageinspect.control b/contrib/pageinspect/pageinspect.control
index cfc87feac03..aee3f598a9e 100644
--- a/contrib/pageinspect/pageinspect.control
+++ b/contrib/pageinspect/pageinspect.control
@@ -1,5 +1,5 @@
 # pageinspect extension
 comment = 'inspect the contents of database pages at a low level'
-default_version = '1.13'
+default_version = '1.14'
 module_pathname = '$libdir/pageinspect'
 relocatable = true
diff --git a/doc/src/sgml/pageinspect.sgml b/doc/src/sgml/pageinspect.sgml
index 3a113439e1d..b16a675099f 100644
--- a/doc/src/sgml/pageinspect.sgml
+++ b/doc/src/sgml/pageinspect.sgml
@@ -518,6 +518,110 @@ test=# SELECT itemoffset, ctid, itemlen, nulls, vars, data, dead, htid, tids[0:2
      </para>
     </listitem>
    </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>bt_find_merge_candidates(relname text, merge_candidate_max_pct float8 DEFAULT 10.0, merge_destination_max_pct float8 DEFAULT 90.0, num_pages int4 DEFAULT 1) returns setof record</function>
+     <indexterm>
+      <primary>bt_find_merge_candidates</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <function>bt_find_merge_candidates</function> walks the leaf level
+      of a B-tree index from left to right and returns up to
+      <parameter>num_pages</parameter> pairs of adjacent leaf pages that
+      could be merged.  A pair qualifies when each page is at most
+      <parameter>merge_candidate_max_pct</parameter> percent full, the
+      two pages together are at most
+      <parameter>merge_destination_max_pct</parameter> percent of a page,
+      and both pages have the same parent.  The leftmost leaf page is
+      never part of a pair.  The free space columns are percentages of
+      the page size; <structfield>result_free_space</structfield> is an
+      estimate of the free space left after the merge.  The index is not
+      changed.  For example:
+<screen>
+test=# SELECT * FROM bt_find_merge_candidates('t_idx', 10.0, 90.0, 3);
+ left_blkno | right_blkno | free_space_left | free_space_right | result_free_space
+------------+-------------+-----------------+------------------+-------------------
+          2 |           4 |     94.82421875 |      94.82421875 |        89.6484375
+          4 |           5 |     94.82421875 |     94.580078125 |      89.404296875
+          5 |           6 |    94.580078125 |      94.82421875 |      89.404296875
+(3 rows)
+</screen>
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>bt_merge_detail(relname text, left_blkno int8, show_tids boolean) returns setof record</function>
+     <indexterm>
+      <primary>bt_merge_detail</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <function>bt_merge_detail</function> shows the three pages a merge
+      of leaf page <parameter>left_blkno</parameter> would change: the
+      parent, the left page, and its right sibling, in that order.
+      <structfield>first_val</structfield> and
+      <structfield>last_val</structfield> show the first and last keys on
+      a leaf page, and <structfield>high_key</structfield> its high key, in
+      the same format as the <structfield>data</structfield> column of
+      <function>bt_page_items</function>.  When
+      <parameter>show_tids</parameter> is true,
+      <structfield>tids</structfield> holds the heap TIDs of a leaf page,
+      with posting lists expanded, or the downlinks of the parent page.
+      An error is raised if <parameter>left_blkno</parameter> is not a live leaf page,
+      is the rightmost leaf page, or has a different parent than its
+      right sibling.  For example:
+<screen>
+test=# SELECT page_role, blkno, btpo_prev, btpo_next, btpo_flags, free_size, num_tids, first_val, last_val
+        FROM bt_merge_detail('t_idx', 2, false);
+ page_role | blkno | btpo_prev | btpo_next | btpo_flags | free_size | num_tids |        first_val        |        last_val
+-----------+-------+-----------+-----------+------------+-----------+----------+-------------------------+-------------------------
+ parent    |     3 |         0 |         0 |          2 |      7596 |       28 |                         |
+ left      |     2 |         1 |         4 |          1 |      7768 |       18 | 7c 01 00 00 00 00 00 00 | d0 02 00 00 00 00 00 00
+ right     |     4 |         2 |         5 |          1 |      7768 |       18 | e4 02 00 00 00 00 00 00 | 38 04 00 00 00 00 00 00
+(3 rows)
+</screen>
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>bt_merge(relname text, merge_candidate_max_pct float8 DEFAULT 10.0, merge_destination_max_pct float8 DEFAULT 90.0, num_pages int4 DEFAULT 1) returns int4</function>
+     <indexterm>
+      <primary>bt_merge</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <function>bt_merge</function> merges pairs of leaf pages chosen by
+      the same rules as <function>bt_find_merge_candidates</function>, and
+      returns the number of merges done.  Unlike the other functions in
+      this module, it <emphasis>modifies the index</emphasis>; it is
+      intended for testing.  <parameter>num_pages</parameter> is the
+      number of leaf pages examined, not the number of merges.  The tuples
+      of the left page are moved into its right sibling, and the left page
+      is left as a tombstone, which <function>bt_page_stats</function>
+      reports with type <literal>m</literal>.  A later
+      <command>VACUUM</command> removes it.  For example:
+<screen>
+test=# SELECT bt_merge('t_idx', 10.0, 90.0, 100);
+ bt_merge
+----------
+       13
+(1 row)
+</screen>
+     </para>
+    </listitem>
+   </varlistentry>
   </variablelist>
  </sect2>
 
-- 
2.43.0

