From a586b9f1ba41b9b7f2c9bfa713531a2e0d9f5953 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Sat, 7 Nov 2020 14:07:14 -0800
Subject: [PATCH v7 3/4] Teach nbtree to use bottom-up index deletion.

Teach nbtree to eagerly delete duplicate of tuples representing old
versions in the event of a localized flood of version churn.  This
situation is detected using heuristics, including the recently added
"index is logically unchanged by an UPDATE" executor hint.

This commit alone changes very little about how nbtree behaves.  We
still lack tableam-side support.  An upcoming commit that adds support
for bottom-up index deletion to heapam will follow, completing the
picture.

The immediate goal of bottom-up index deletion in nbtree is to avoid
"unnecessary" page splits caused entirely by duplicates needed only for
MVCC/versioning purposes.  It naturally has an even more useful effect,
though: it acts as a backstop against accumulating an excessive number
of index tuple versions for any given _logical row_.  Note that the
relationship between this localized condition and the proportion of
garbage tuples in the entire index is very loose, and can be very
volatile.  Bottom-up index deletion complements what we might now call
"top-down index deletion": index vacuuming performed by VACUUM.  It
responds to the immediate local needs of queries, while leaving it up to
autovacuum to perform infrequent clean sweeps of the index.

Bottom-up index deletion is very effective despite not changing anything
about the fundamental invariants for Postgres index access methods in
general (and despite not changing any invariants for nbtree in
particular).  That is, it is still inherently necessary to keep around
multiple versions together on the same leaf page, at least in some cases
(no change there).  But nothing forbids us from being proactive in
keeping the number of tuples for any given logical row under control, if
and when that seems to makes sense at the page/local level.  In practice
it is seldom strictly necessary to have more than a couple of physical
index tuple versions present for any given logical row.

You can think of bottom-up index deletion as bringing the effectiveness
of garbage collection in nbtree far closer to the true theoretical
limits imposed on it by the core system.  In practice nbtree could fall
significantly short of this ideal before now, often in ways that could
not easily be predicted or reasoned about, and often in the absence of
obvious stressors (like very long running transactions that hold open an
MVCC snapshot).  This may not have happened in production all that
often, but when it happened it had a significant impact on query
latency, often at the most inconvenient time possible.

Handling of the various strategies that we have to avoid a page split
when it looks like a leaf page is about to overflow (deleting LP_DEAD
items, deduplication, and now bottom-up deletion) is streamlined by this
commit.  This has an independently useful consequence: nbtree no longer
relies on the BTP_HAS_GARBAGE page level flag/hint for anything
important.  We still set and unset the flag in the same way as before,
but it's no longer treated as a gating condition when considering if we
should check for already-set LP_DEAD bits.  This happens at the point
where the page looks like it might have to be split anyway, so simply
checking the LP_DEAD bits in passing is practically free.  This avoids
missing LP_DEAD bits just because the page-level hint is unset, which is
probably reasonably common (e.g. it happens when VACUUM unsets the
page-level flag without actually removing index tuples whose LP_DEAD-bit
was set recently, after the VACUUM operation began but before it reached
the leaf page in question).

(We don't completely remove the BTP_HAS_GARBAGE flag because we still
rely on it as a gating condition with pg_upgrade'd indexes from before
B-tree version 4/PostgreSQL 12.  That makes sense because we sometimes
have to make a choice among pages full of duplicates when inserting a
tuple with pre version 4 indexes.  It probably still pays to avoid
accessing the line pointer array of a page there, since it won't yet be
clear whether we'll insert on to the page in question at all, let alone
split it as a result.)

Bump XLOG_PAGE_MAGIC because xl_btree_delete changed.

No bump in BTREE_VERSION, since the on-disk representation of nbtree
indexes has not been changed.  Indexes built on PostgreSQL 12 or
PostgreSQL 13 will be able to use the optimization without a REINDEX
follow an upgrade using pg_upgrade.  Users upgrading from earlier
PostgreSQL versions using pg_upgrade will need to REINDEX in order to
use the optimization, however.

Author: Peter Geoghegan <pg@bowt.ie>
Reviewed-By: Victor Yegorov <vyegorov@gmail.com>
Discussion: https://postgr.es/m/CAH2-Wzm+maE3apHB8NOtmM=p-DO65j2V5GzAWCOEEuy3JZgb2g@mail.gmail.com
---
 src/include/access/nbtree.h           |  15 +-
 src/include/access/nbtxlog.h          |   9 +-
 src/backend/access/nbtree/README      |  73 ++-
 src/backend/access/nbtree/nbtdedup.c  | 629 +++++++++++++++++++++++---
 src/backend/access/nbtree/nbtinsert.c | 209 ++++++---
 src/backend/access/nbtree/nbtpage.c   | 171 +++++--
 src/backend/access/nbtree/nbtree.c    |   2 +-
 src/backend/access/nbtree/nbtutils.c  |   3 +-
 src/backend/access/nbtree/nbtxlog.c   |  54 ++-
 9 files changed, 997 insertions(+), 168 deletions(-)

diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index f002e5ff17..f8faae525a 100644
--- a/src/include/access/nbtree.h
+++ b/src/include/access/nbtree.h
@@ -75,7 +75,7 @@ typedef BTPageOpaqueData *BTPageOpaque;
 #define BTP_META		(1 << 3)	/* meta-page */
 #define BTP_HALF_DEAD	(1 << 4)	/* empty, but still in tree */
 #define BTP_SPLIT_END	(1 << 5)	/* rightmost page of split group */
-#define BTP_HAS_GARBAGE (1 << 6)	/* page has LP_DEAD tuples */
+#define BTP_HAS_GARBAGE (1 << 6)	/* page has LP_DEAD tuples (deprecated) */
 #define BTP_INCOMPLETE_SPLIT (1 << 7)	/* right sibling's downlink is missing */
 
 /*
@@ -1028,9 +1028,11 @@ extern void _bt_parallel_advance_array_keys(IndexScanDesc scan);
 /*
  * prototypes for functions in nbtdedup.c
  */
-extern void _bt_dedup_one_page(Relation rel, Buffer buf, Relation heapRel,
-							   IndexTuple newitem, Size newitemsz,
-							   bool checkingunique);
+extern void _bt_dedup_pass(Relation rel, Buffer buf, Relation heapRel,
+						   IndexTuple newitem, Size newitemsz,
+						   bool checkingunique);
+extern bool _bt_bottomup_pass(Relation rel, Buffer buf, Relation heapRel,
+							  Size newitemsz, bool checkingunique);
 extern void _bt_dedup_start_pending(BTDedupState state, IndexTuple base,
 									OffsetNumber baseoff);
 extern bool _bt_dedup_save_htid(BTDedupState state, IndexTuple itup);
@@ -1045,7 +1047,8 @@ extern IndexTuple _bt_swap_posting(IndexTuple newitem, IndexTuple oposting,
  * prototypes for functions in nbtinsert.c
  */
 extern bool _bt_doinsert(Relation rel, IndexTuple itup,
-						 IndexUniqueCheck checkUnique, Relation heapRel);
+						 IndexUniqueCheck checkUnique, Relation heapRel,
+						 bool indexunchanged);
 extern void _bt_finish_split(Relation rel, Buffer lbuf, BTStack stack);
 extern Buffer _bt_getstackbuf(Relation rel, BTStack stack, BlockNumber child);
 
@@ -1084,7 +1087,9 @@ extern void _bt_delitems_vacuum(Relation rel, Buffer buf,
 								OffsetNumber *deletable, int ndeletable,
 								BTVacuumPosting *updatable, int nupdatable);
 extern void _bt_delitems_delete(Relation rel, Buffer buf,
+								bool bottomup, TransactionId bottomupXid,
 								OffsetNumber *deletable, int ndeletable,
+								BTVacuumPosting *updatable, int nupdatable,
 								Relation heapRel);
 extern uint32 _bt_pagedel(Relation rel, Buffer leafbuf,
 						  TransactionId *oldestBtpoXact);
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 5c014bdc66..4006872d7a 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -187,12 +187,15 @@ typedef struct xl_btree_dedup
 typedef struct xl_btree_delete
 {
 	TransactionId latestRemovedXid;
-	uint32		ndeleted;
+	uint16		ndeleted;
+	uint16		nupdated;
 
 	/* DELETED TARGET OFFSET NUMBERS FOLLOW */
+	/* UPDATED TARGET OFFSET NUMBERS FOLLOW */
+	/* UPDATED TUPLES METADATA ARRAY FOLLOWS */
 } xl_btree_delete;
 
-#define SizeOfBtreeDelete	(offsetof(xl_btree_delete, ndeleted) + sizeof(uint32))
+#define SizeOfBtreeDelete	(offsetof(xl_btree_delete, nupdated) + sizeof(uint16))
 
 /*
  * This is what we need to know about page reuse within btree.  This record
@@ -213,7 +216,7 @@ typedef struct xl_btree_reuse_page
 /*
  * This is what we need to know about which TIDs to remove from an individual
  * posting list tuple during vacuuming.  An array of these may appear at the
- * end of xl_btree_vacuum records.
+ * end of xl_btree_vacuum and xl_btree_delete records.
  */
 typedef struct xl_btree_update
 {
diff --git a/src/backend/access/nbtree/README b/src/backend/access/nbtree/README
index 27f555177e..0ce27c09d3 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -439,12 +439,14 @@ from the index immediately; since index scans only stop "between" pages,
 no scan can lose its place from such a deletion.  We separate the steps
 because we allow LP_DEAD to be set with only a share lock (it's exactly
 like a hint bit for a heap tuple), but physically removing tuples requires
-exclusive lock.  In the current code we try to remove LP_DEAD tuples when
-we are otherwise faced with having to split a page to do an insertion (and
-hence have exclusive lock on it already).  Deduplication can also prevent
-a page split, but removing LP_DEAD tuples is the preferred approach.
-(Note that posting list tuples can only have their LP_DEAD bit set when
-every table TID within the posting list is known dead.)
+exclusive lock.  We try to remove LP_DEAD tuples when we are otherwise
+faced with having to split a page to do an insertion (and hence have
+exclusive lock on it already).  Deduplication and bottom-up index deletion
+can also prevent a page split, but removing LP_DEAD tuples is always the
+preferred approach.  (Note that posting list tuples can only have their
+LP_DEAD bit set when every table TID within the posting list is known
+dead.  This isn't much of a problem because bottom-up deletion supports
+granular deletion of TIDs from posting lists.)
 
 This leaves the index in a state where it has no entry for a dead tuple
 that still exists in the heap.  This is not a problem for the current
@@ -767,9 +769,10 @@ into a single physical tuple with a posting list (a simple array of heap
 TIDs with the standard item pointer format).  Deduplication is always
 applied lazily, at the point where it would otherwise be necessary to
 perform a page split.  It occurs only when LP_DEAD items have been
-removed, as our last line of defense against splitting a leaf page.  We
-can set the LP_DEAD bit with posting list tuples, though only when all
-TIDs are known dead.
+removed, as our last line of defense against splitting a leaf page
+(bottom-up index vacuuming may be attempted first, as our second last line
+of defense).  We can set the LP_DEAD bit with posting list tuples, though
+only when all TIDs are known dead.
 
 Our lazy approach to deduplication allows the page space accounting used
 during page splits to have absolutely minimal special case logic for
@@ -826,6 +829,16 @@ delay a split that is probably inevitable anyway.  This allows us to avoid
 the overhead of attempting to deduplicate with unique indexes that always
 have few or no duplicates.
 
+Note: Avoiding "unnecessary" page splits driven by version churn is also
+the goal of bottom-up index deletion, which was added to PostgreSQL 14.
+Bottom-up index deletion is now the preferred way to deal with this
+problem (with all kinds of indexes, though especially with unique
+indexes).  Still, deduplication can sometimes augment bottom-up index
+deletion.  When deletion cannot free tuples (due to an old snapshot
+holding up cleanup), falling back on deduplication provides additional
+capacity.  Delaying the page split by deduplicating can allow a future
+bottom-up deletion pass of the same page to succeed.
+
 Posting list splits
 -------------------
 
@@ -880,6 +893,48 @@ that need a page split anyway.  Besides, supporting variable "split points"
 while splitting posting lists won't actually improve overall space
 utilization.
 
+Bottom-up index deletion
+------------------------
+
+We sometimes delete whatever duplicates happen to be present on the page
+before moving on to deduplication.  This only happens when we receive a
+hint that optimizations like heapam's HOT have not worked out for the
+index -- the incoming tuple must be a logically unchanged duplicate which
+is needed for MVCC purposes.  (Actually it also happens with unique
+indexes in some extra cases that don't get this hint.)
+
+There are certain ways in which this mechanism is similar to on-the-fly
+deletion of index tuples (that will already have failed to prevent a page
+split by the time bottom-up deletion is attempted).  For example, the same
+WAL records are used.  There are also significant differences.  Index
+tuples that get deleted by this mechanism won't have already been marked
+LP_DEAD in passing by queries.  Rather, we figure out whether or not
+they're deletable in principle at the last point before splitting a page
+by accessing tableam blocks to get visibility information.  We us
+heuristics to access as few tableam blocks as possible while still
+expecting to find a reasonably large number of tuples that are safe to
+delete each time.  We expect to perform regular bottom-up deletion
+operations against pages that are at constant risk of unnecessary page
+splits caused only by version churn.  When the mechanism works well we'll
+constantly be on the verge of having lots of version churn driven page
+splits, but never actually have any.
+
+Bottom-up index deletion can be thought of as a backstop mechanism against
+unnecessary version-driven page splits.  When we have a reasonable
+suspicion that a would-be page split may not actually be necessary, we
+fight back.  There is very little to lose and much to gain by spending a
+few cycles to become reasonably sure that it is in fact necessary -- page
+splits are very expensive and practically irreversible.  This approach
+works well in a large variety of workloads because we give up before
+spending very many cycles on trying, and because our heuristics are good
+enough to spot unnecessary page splits fairly reliably in practice.
+Unnecessary page splits occur due to pathological amounts of version
+churn.  In practice this pathological condition can be detected before too
+long using simple heuristics.  We don't have to understand the universe of
+possible workloads; we only have to understand the nature of the
+underlying pathology.  We're helped out by additional heuristics within
+tableams such as heapam.
+
 Notes About Data Representation
 -------------------------------
 
diff --git a/src/backend/access/nbtree/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c
index f6be865b17..e389f8c6b6 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -16,21 +16,23 @@
 
 #include "access/nbtree.h"
 #include "access/nbtxlog.h"
+#include "access/tableam.h"
 #include "miscadmin.h"
 #include "utils/rel.h"
 
+static int _bt_bottomup_finish_pending(Page page, TM_IndexDeleteOp *vstate,
+									   BTDedupState state);
 static bool _bt_do_singleval(Relation rel, Page page, BTDedupState state,
 							 OffsetNumber minoff, IndexTuple newitem);
 static void _bt_singleval_fillfactor(Page page, BTDedupState state,
 									 Size newitemsz);
+static int	_bt_indexdelete_cmp(const void *a, const void *b, void *arg);
 #ifdef USE_ASSERT_CHECKING
 static bool _bt_posting_valid(IndexTuple posting);
 #endif
 
 /*
- * Deduplicate items on a leaf page.  The page will have to be split by caller
- * if we cannot successfully free at least newitemsz (we also need space for
- * newitem's line pointer, which isn't included in caller's newitemsz).
+ * Perform a deduplication pass.
  *
  * The general approach taken here is to perform as much deduplication as
  * possible to free as much space as possible.  Note, however, that "single
@@ -43,76 +45,32 @@ static bool _bt_posting_valid(IndexTuple posting);
  * handle those if and when the anticipated right half page gets its own
  * deduplication pass, following further inserts of duplicates.)
  *
- * This function should be called during insertion, when the page doesn't have
- * enough space to fit an incoming newitem.  If the BTP_HAS_GARBAGE page flag
- * was set, caller should have removed any LP_DEAD items by calling
- * _bt_vacuum_one_page() before calling here.  We may still have to kill
- * LP_DEAD items here when the page's BTP_HAS_GARBAGE hint is falsely unset,
- * but that should be rare.  Also, _bt_vacuum_one_page() won't unset the
- * BTP_HAS_GARBAGE flag when it finds no LP_DEAD items, so a successful
- * deduplication pass will always clear it, just to keep things tidy.
+ * The page will have to be split if we cannot successfully free at least
+ * newitemsz (we also need space for newitem's line pointer, which isn't
+ * included in caller's newitemsz).
+ *
+ * Note: Caller should have already deleted all existing items with their
+ * LP_DEAD bits set.
  */
 void
-_bt_dedup_one_page(Relation rel, Buffer buf, Relation heapRel,
-				   IndexTuple newitem, Size newitemsz, bool checkingunique)
+_bt_dedup_pass(Relation rel, Buffer buf, Relation heapRel, IndexTuple newitem,
+			   Size newitemsz, bool checkingunique)
 {
 	OffsetNumber offnum,
 				minoff,
 				maxoff;
 	Page		page = BufferGetPage(buf);
-	BTPageOpaque opaque;
+	BTPageOpaque opaque = (BTPageOpaque) PageGetSpecialPointer(page);
 	Page		newpage;
-	OffsetNumber deletable[MaxIndexTuplesPerPage];
 	BTDedupState state;
-	int			ndeletable = 0;
 	Size		pagesaving = 0;
 	bool		singlevalstrat = false;
 	int			nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
 
-	/*
-	 * We can't assume that there are no LP_DEAD items.  For one thing, VACUUM
-	 * will clear the BTP_HAS_GARBAGE hint without reliably removing items
-	 * that are marked LP_DEAD.  We don't want to unnecessarily unset LP_DEAD
-	 * bits when deduplicating items.  Allowing it would be correct, though
-	 * wasteful.
-	 */
-	opaque = (BTPageOpaque) PageGetSpecialPointer(page);
-	minoff = P_FIRSTDATAKEY(opaque);
-	maxoff = PageGetMaxOffsetNumber(page);
-	for (offnum = minoff;
-		 offnum <= maxoff;
-		 offnum = OffsetNumberNext(offnum))
-	{
-		ItemId		itemid = PageGetItemId(page, offnum);
-
-		if (ItemIdIsDead(itemid))
-			deletable[ndeletable++] = offnum;
-	}
-
-	if (ndeletable > 0)
-	{
-		_bt_delitems_delete(rel, buf, deletable, ndeletable, heapRel);
-
-		/*
-		 * Return when a split will be avoided.  This is equivalent to
-		 * avoiding a split using the usual _bt_vacuum_one_page() path.
-		 */
-		if (PageGetFreeSpace(page) >= newitemsz)
-			return;
-
-		/*
-		 * Reconsider number of items on page, in case _bt_delitems_delete()
-		 * managed to delete an item or two
-		 */
-		minoff = P_FIRSTDATAKEY(opaque);
-		maxoff = PageGetMaxOffsetNumber(page);
-	}
-
 	/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
 	newitemsz += sizeof(ItemIdData);
 
 	/*
-	 * By here, it's clear that deduplication will definitely be attempted.
 	 * Initialize deduplication state.
 	 *
 	 * It would be possible for maxpostingsize (limit on posting list tuple
@@ -138,6 +96,9 @@ _bt_dedup_one_page(Relation rel, Buffer buf, Relation heapRel,
 	/* nintervals should be initialized to zero */
 	state->nintervals = 0;
 
+	minoff = P_FIRSTDATAKEY(opaque);
+	maxoff = PageGetMaxOffsetNumber(page);
+
 	/* Determine if "single value" strategy should be used */
 	if (!checkingunique)
 		singlevalstrat = _bt_do_singleval(rel, page, state, minoff, newitem);
@@ -259,10 +220,9 @@ _bt_dedup_one_page(Relation rel, Buffer buf, Relation heapRel,
 	/*
 	 * By here, it's clear that deduplication will definitely go ahead.
 	 *
-	 * Clear the BTP_HAS_GARBAGE page flag in the unlikely event that it is
-	 * still falsely set, just to keep things tidy.  (We can't rely on
-	 * _bt_vacuum_one_page() having done this already, and we can't rely on a
-	 * page split or VACUUM getting to it in the near future.)
+	 * Clear the BTP_HAS_GARBAGE page flag.  The index must be a heapkeyspace
+	 * index, and as such we'll never pay attention to BTP_HAS_GARBAGE anyway.
+	 * But keep things tidy.
 	 */
 	if (P_HAS_GARBAGE(opaque))
 	{
@@ -311,6 +271,343 @@ _bt_dedup_one_page(Relation rel, Buffer buf, Relation heapRel,
 	pfree(state);
 }
 
+/*
+ * Perform bottom-up index deletion pass.
+ *
+ * See if duplicate index tuples are eligible to be deleted by accessing
+ * visibility information from the tableam.  Give up if we have to access more
+ * than a few tableam blocks.  Caller tries to avoid "unnecessary" page splits
+ * (splits driven only by version churn) by calling here when it looks like
+ * that's about to happen.  It's normal for there to be a lot of calls here
+ * for pages that are constantly at risk of an unnecessary split.
+ *
+ * Each failure to delete a duplicate/promising tuple here is a kind of
+ * learning experience.  It results in caller falling back on splitting the
+ * page (or on a deduplication pass), discouraging future calls back here for
+ * the same key space range covered by a failed page (or at least discouraging
+ * processing the original duplicates in case where caller falls back on a
+ * successful deduplication pass).  We converge on the most effective strategy
+ * for each page in the index over time, sometimes through trial and error.
+ *
+ * The page will have to be split if we cannot successfully free at least
+ * newitemsz (we also need space for newitem's line pointer, which isn't
+ * included in caller's newitemsz).
+ *
+ * Returns true on success, in which case caller can assume page split will be
+ * avoided for a reasonable amount of time.  Returns false when caller should
+ * deduplicate the page (if possible at all).
+ *
+ * Note: Caller should have already deleted all existing items with their
+ * LP_DEAD bits set.
+ *
+ * Note: occasionally a true return value does not indicate success -- it just
+ * indicates that caller should not go on to perform a deduplication pass.
+ * Caller is not expected to care about the difference.
+ */
+bool
+_bt_bottomup_pass(Relation rel, Buffer buf, Relation heapRel, Size newitemsz,
+				  bool checkingunique)
+{
+	OffsetNumber offnum,
+				minoff,
+				maxoff,
+				postingidxoffnum;
+	Page		page = BufferGetPage(buf);
+	BTPageOpaque opaque = (BTPageOpaque) PageGetSpecialPointer(page);
+	BTDedupState state;
+	TM_IndexDeleteOp vstate;
+	int			nhtidspage = 0;
+	int			ndistinctvalspage = 0;
+	TransactionId latestRemovedXid;
+	int			ndeletable,
+				nupdatable;
+	OffsetNumber deletable[MaxIndexTuplesPerPage];
+	BTVacuumPosting updatable[MaxIndexTuplesPerPage];
+	int			nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+
+	/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
+	newitemsz += sizeof(ItemIdData);
+
+	/* Initialize deduplication state */
+	state = (BTDedupState) palloc(sizeof(BTDedupStateData));
+	state->deduplicate = true;
+	state->nmaxitems = 0;
+	state->maxpostingsize = BLCKSZ;	/* "posting list size" not a concern */
+	state->base = NULL;
+	state->baseoff = InvalidOffsetNumber;
+	state->basetupsize = 0;
+	state->htids = palloc(state->maxpostingsize);
+	state->nhtids = 0;
+	state->nitems = 0;
+	state->phystupsize = 0;
+	state->nintervals = 0;
+
+	/*
+	 * Initialize tableam state that describes bottom-up index deletion
+	 * operation.
+	 *
+	 * We will ask tableam to free 1/16 of BLCKSZ.  We don't usually expect to
+	 * have to free much space each call here in order to avoid page splits.
+	 * We don't want to be too aggressive since in general the tableam will
+	 * have to access more table blocks when we ask for more free space.  In
+	 * general we try to be conservative about what we ask for (though not too
+	 * conservative), while leaving it up to the tableam to ramp up the number
+	 * of tableam blocks accessed when conditions in the table structure
+	 * happen to favor it.
+	 *
+	 * We expect to end up back here again and again for any leaf page that is
+	 * more or less constantly at risk of unnecessary page splits -- in fact
+	 * that's what happens when bottom-up deletion really helps.  We must
+	 * avoid thrashing when this becomes very frequent at the level of an
+	 * individual page.  Our free space target helps with that.  It balances
+	 * the costs and benefits over time and across related bottom-up deletion
+	 * passes.
+	 */
+	vstate.ndeltids = 0;
+	vstate.deltids = palloc(MaxTIDsPerBTreePage * sizeof(TM_IndexDelete));
+	vstate.tidstatuses = palloc(MaxTIDsPerBTreePage * sizeof(TM_IndexStatus));
+	vstate.targetfreespace = Max(BLCKSZ / 16, newitemsz);
+
+	/* Now remember details of the page in the state we'll pass to tableam */
+	minoff = P_FIRSTDATAKEY(opaque);
+	maxoff = PageGetMaxOffsetNumber(page);
+	for (offnum = minoff;
+		 offnum <= maxoff;
+		 offnum = OffsetNumberNext(offnum))
+	{
+		ItemId		itemid = PageGetItemId(page, offnum);
+		IndexTuple	itup = (IndexTuple) PageGetItem(page, itemid);
+
+		Assert(!ItemIdIsDead(itemid));
+
+		if (offnum == minoff)
+		{
+			_bt_dedup_start_pending(state, itup, offnum);
+		}
+		else if (_bt_keep_natts_fast(rel, state->base, itup) > nkeyatts &&
+				 _bt_dedup_save_htid(state, itup))
+		{
+			/* Tuple is equal to base tuple of pending interval */
+		}
+		else
+		{
+			/* Handle interval -- save TIDs in deltids now */
+			nhtidspage += _bt_bottomup_finish_pending(page, &vstate, state);
+			ndistinctvalspage++;
+
+			/* itup starts new pending interval */
+			_bt_dedup_start_pending(state, itup, offnum);
+		}
+	}
+	/* Handle final interval -- save TIDs in deltids now */
+	nhtidspage += _bt_bottomup_finish_pending(page, &vstate, state);
+	ndistinctvalspage++;
+
+	if (state->nintervals == 0)
+	{
+		/*
+		 * No duplicates on existing page.  Apparently the incoming item that
+		 * won't fit on the page just so happens to be the first duplicate
+		 * version tuple that belongs on the page.  This is only barely
+		 * possible.
+		 *
+		 * Tell caller we were successful here to prevent a deduplication
+		 * pass, even though technically that's a lie.  This is a simple way
+		 * of avoiding wasting more cycles on the page.  The page will have to
+		 * be split.
+		 */
+		pfree(state->htids);
+		pfree(state);
+		pfree(vstate.deltids);
+		pfree(vstate.tidstatuses);
+
+		return true;
+	}
+
+	/* Done with dedup state */
+	pfree(state->htids);
+	pfree(state);
+
+	/* Now use tableam interface to determine which tuples to delete */
+	latestRemovedXid = table_index_delete_check(heapRel, &vstate);
+
+	if (vstate.ndeltids == 0)
+	{
+		/* The tableam has nothing for us */
+		pfree(vstate.deltids);
+		pfree(vstate.tidstatuses);
+		return false;
+	}
+
+	/*
+	 * By here we know that we have at least one deletable index tuple (or
+	 * TID) in final deltids array.  All that remains is to construct a
+	 * leaf-page-wise description of what _bt_delitems_delete() needs to do to
+	 * physically delete index tuples from the page.
+	 *
+	 * Sort deltids array (which is typically much smaller now) in the order
+	 * expected by loop: the original leaf-page-wise order (the order the
+	 * array was in before the tableam sorted it for its own reasons).
+	 */
+	qsort_arg(vstate.deltids, vstate.ndeltids, sizeof(TM_IndexDelete),
+			  _bt_indexdelete_cmp, vstate.tidstatuses);
+	postingidxoffnum = InvalidOffsetNumber;
+	ndeletable = 0;
+	nupdatable = 0;
+	for (int i = 0; i < vstate.ndeltids; i++)
+	{
+		TM_IndexStatus *status = vstate.tidstatuses + vstate.deltids[i].id;
+		OffsetNumber idxoffnum = status->idxoffnum;
+		ItemId		itemid = PageGetItemId(page, idxoffnum);
+		IndexTuple	itup;
+		int			nitem;
+		BTVacuumPosting vacposting = NULL;
+
+		itup = (IndexTuple) PageGetItem(page, itemid);
+
+		if (idxoffnum == postingidxoffnum)
+		{
+			/*
+			 * This deltid entry is a TID from a posting list tuple that has
+			 * already been completely processed (since we do whole posting
+			 * lists all at once, when the first deltid for the posting list
+			 * tuple is encountered -- see below).
+			 */
+			Assert(BTreeTupleIsPosting(itup));
+			continue;
+		}
+
+		if (!BTreeTupleIsPosting(itup))
+		{
+			/*
+			 * For a plain non-pivot tuple, simply being found marked dead
+			 * means we can kill
+			 */
+			Assert(ItemPointerEquals(&itup->t_tid, &vstate.deltids[i].tid));
+			if (status->deleteitup)
+				deletable[ndeletable++] = idxoffnum;
+			continue;
+		}
+
+		/*
+		 * For a posting list tuple we have to work a bit harder, since we may
+		 * either delete or update (i.e. update to delete a subset of its
+		 * TIDs).
+		 *
+		 * We'll be skipping over future TIDs from this same posting list at
+		 * the top of the outer loop, since we want to process all TIDs in
+		 * tuple together once in inner loop.  Remember to do that now.
+		 */
+		postingidxoffnum = idxoffnum;
+		nitem = BTreeTupleGetNPosting(itup);
+		for (int j = 0; j < nitem; j++)
+		{
+			ItemPointer htid;
+			int			cmp;
+			int			tidi = i;
+
+			htid = BTreeTupleGetPostingN(itup, j);
+
+			for (;;)
+			{
+				cmp = ItemPointerCompare(htid, &vstate.deltids[tidi].tid);
+				if (cmp == 0)
+					break;
+				tidi++;
+				if (tidi >= vstate.ndeltids ||
+					(vstate.tidstatuses + vstate.deltids[tidi].id)->idxoffnum != idxoffnum)
+				{
+					/*
+					 * If this later tidi deltid doesn't even relate to same
+					 * posting list index tuple from page, we're done with
+					 * this TID from itup (posting list tuple).
+					 */
+					cmp = -1;
+					break;
+				}
+			}
+
+			/* Final check for exact TID match */
+			if (cmp != 0)
+				continue;
+
+			/* Only interested in dead TIDs */
+			if (!(vstate.tidstatuses + vstate.deltids[tidi].id)->deleteitup)
+				continue;
+
+			if (vacposting == NULL)
+			{
+				/*
+				 * First dead table TID encountered.
+				 *
+				 * Start maintaining metadata describing how to update
+				 * existing posting list tuple.
+				 */
+				vacposting = palloc(offsetof(BTVacuumPostingData, deletetids) +
+									nitem * sizeof(uint16));
+				vacposting->itup = itup;
+				vacposting->updatedoffset = idxoffnum;
+				vacposting->ndeletedtids = 0;
+			}
+			vacposting->deletetids[vacposting->ndeletedtids++] = j;
+		}
+
+		/* Decide what to do with TIDs in posting list tuple */
+
+		if (vacposting == NULL)
+		{
+			/* No TIDs to delete in posting list tuple -- do nothing */
+		}
+		else if (vacposting->ndeletedtids == nitem)
+		{
+			/* Straight delete -- won't need update state */
+			deletable[ndeletable++] = idxoffnum;
+			pfree(vacposting);
+		}
+		else
+		{
+			/* Going to delete some but not all TIDs in posting list */
+			Assert(vacposting->ndeletedtids > 0 &&
+				   vacposting->ndeletedtids < nitem);
+			updatable[nupdatable++] = vacposting;
+		}
+	}
+
+	/* Done with deltids state */
+	pfree(vstate.deltids);
+	pfree(vstate.tidstatuses);
+
+	/*
+	 * Go through with deleting TIDs that we found are safe to delete.
+	 *
+	 * No MarkBufferDirtyHint() call is needed here, since we don't ever mark
+	 * line pointers LP_DEAD.  Any and all modifications to the page are made
+	 * in the critical section in _bt_delitems_delete().
+	 */
+	_bt_delitems_delete(rel, buf, true, latestRemovedXid,
+						deletable, ndeletable, updatable, nupdatable,
+						heapRel);
+
+	/* be tidy */
+	for (int i = 0; i < nupdatable; i++)
+		pfree(updatable[i]);
+
+	/*
+	 * If page is low cardinality, we should deduplicate proactively.  We do
+	 * so even when we managed to delete many TIDs.
+	 *
+	 * This provides useful back pressure against intense version churn
+	 * causing thrashing.  We don't want to end up here again for the same
+	 * leaf page in the near future.
+	 */
+	if (nhtidspage > ndistinctvalspage * 3)
+		return false;
+
+	/* Don't dedup when we won't end up back here any time soon anyway */
+	return PageGetExactFreeSpace(page) >=
+		   Max(vstate.targetfreespace / 2, newitemsz);
+}
+
 /*
  * Create a new pending posting list tuple based on caller's base tuple.
  *
@@ -496,6 +793,171 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
 	return spacesaving;
 }
 
+/*
+ * Finalize interval during bottom-up index deletion.
+ *
+ * Determines which TIDs are to be marked promising based no heuristics.
+ *
+ * Returns number of heap TIDs in pending interval, regardless of whether it
+ * is remembered as an interval.  Return value can be used to count the
+ * precise number of TIDs on the page in all cases.
+ */
+static int
+_bt_bottomup_finish_pending(Page page, TM_IndexDeleteOp *vstate,
+							BTDedupState state)
+{
+	bool		dupinterval = (state->nitems > 1);
+	int			nhtids = state->nhtids;
+
+	Assert(state->nitems > 0);
+	Assert(state->nitems <= state->nhtids);
+	Assert(state->intervals[state->nintervals].baseoff == state->baseoff);
+
+	/*
+	 * All TIDs from all tuples are at least recording in state.  Tuples are
+	 * marked promising when they're duplicates (i.e. when they appear in an
+	 * interval with more than one item, as when we expect create a new postng
+	 * list tuple in the deduplication case).
+	 *
+	 * It's easy to see what this means in the plain non-pivot tuple case:
+	 * TIDs from duplicate plain tuples are promising.  Posting list tuples
+	 * are more subtle.  We ought to do something with posting list tuples,
+	 * though plain tuples tend to be more promising targets.  (Plain tuples
+	 * are the most likely to be dead/deletable because they suggest version
+	 * churn.  And they allow us to free more space when we actually succeed).
+	 */
+	for (int i = 0; i < state->nitems; i++)
+	{
+		OffsetNumber offnum = state->baseoff + i;
+		ItemId		itemid = PageGetItemId(page, offnum);
+		IndexTuple	itup = (IndexTuple) PageGetItem(page, itemid);
+		TM_IndexDelete *cdeltid;
+		TM_IndexStatus *cstatusdeltid;
+
+		cdeltid = &vstate->deltids[vstate->ndeltids];
+		cstatusdeltid = &vstate->tidstatuses[vstate->ndeltids];
+
+		if (!BTreeTupleIsPosting(itup))
+		{
+			/* Easy case: A plain non-pivot tuple's TID */
+			cdeltid->tid = itup->t_tid;
+			cdeltid->id = vstate->ndeltids;
+			cstatusdeltid->idxoffnum = offnum;
+			cstatusdeltid->ispromising = dupinterval;
+			cstatusdeltid->deleteitup = false;	/* for now */
+			cstatusdeltid->tupsize =
+				ItemIdGetLength(itemid) + sizeof(ItemIdData);
+			vstate->ndeltids++;
+		}
+		else
+		{
+			/*
+			 * Harder case: A posting list tuple's TIDs (multiple TIDs).
+			 *
+			 * Only a single TID from a posting list tuple may be promising,
+			 * and only when it appears in a duplicate tuple (just like plain
+			 * tuple case).  In general there is a good chance that the
+			 * posting list tuple relates to multiple logical rows, rather
+			 * than multiple versions of just one logical row.  (It can only
+			 * be the latter case when a previous bottom-up deletion pass
+			 * failed, necessitating a deduplication pass, which isn't all
+			 * that common.)
+			 *
+			 * There is a pretty good chance that at least one of the logical
+			 * rows from the posting list was updated, and so had a successor
+			 * version (about as good a chance as it is in the regular tuple
+			 * case, at least).  We should at least try to follow the regular
+			 * tuple case while making the conservative assumption that there
+			 * can only be one affected logical row per posting list tuple. We
+			 * do that by picking one TID when it appears to be from the
+			 * predominant tableam block in the posting list (if any one
+			 * tableam block predominates).  The approach we take is to either
+			 * choose the first or last TID in the posting list (if any at
+			 * all).  We go with whichever one is on the same tableam block at
+			 * the middle tuple (and only the first TID when both the first
+			 * and last TIDs relate to the same tableam block -- we could
+			 * easily be too aggressive here).
+			 *
+			 * If it turns out that there are multiple old versions of a
+			 * single logical table row, we still have a pretty good chance of
+			 * being able to delete them this way.  We don't want to give too
+			 * strong a signal to the tableam.  But we should always try to
+			 * give some useful hints.  Even cases with considerable
+			 * uncertainty can consistently avoid an unnecessary page split,
+			 * in part because the tableam will have tricks of its own for
+			 * figuring out where to look in marginal cases.
+			 */
+			int			nitem = BTreeTupleGetNPosting(itup);
+			bool		firstpromise = false;
+			bool		lastpromise = false;
+
+			Assert(_bt_posting_valid(itup));
+
+			if (dupinterval)
+			{
+				/* Figure out if there really should be promising TIDs */
+				BlockNumber minblocklist,
+							midblocklist,
+							maxblocklist;
+				ItemPointer mintid,
+							midtid,
+							maxtid;
+
+				mintid = BTreeTupleGetHeapTID(itup);
+				midtid = BTreeTupleGetPostingN(itup, nitem / 2);
+				maxtid = BTreeTupleGetMaxHeapTID(itup);
+				minblocklist = ItemPointerGetBlockNumber(mintid);
+				midblocklist = ItemPointerGetBlockNumber(midtid);
+				maxblocklist = ItemPointerGetBlockNumber(maxtid);
+
+				firstpromise = (minblocklist == midblocklist);
+				lastpromise = (!firstpromise && midblocklist == maxblocklist);
+			}
+
+			/* Assert that at most one TID is promising */
+			Assert(!firstpromise || !lastpromise);
+
+			for (int p = 0; p < nitem; p++)
+			{
+				ItemPointer htid = BTreeTupleGetPostingN(itup, p);
+
+				cdeltid->tid = *htid;
+				cdeltid->id = vstate->ndeltids;
+				cstatusdeltid->idxoffnum = offnum;
+				cstatusdeltid->ispromising = false;
+
+				if ((firstpromise && p == 0) ||
+					(lastpromise && p == nitem - 1))
+					cstatusdeltid->ispromising = true;
+
+				cstatusdeltid->deleteitup = false;	/* for now */
+				cstatusdeltid->tupsize = sizeof(ItemPointerData) + 1;
+				vstate->ndeltids++;
+
+				cdeltid++;
+				cstatusdeltid++;
+			}
+		}
+	}
+
+	if (dupinterval)
+	{
+		/*
+		 * Maintain interval state for consistency with true deduplication
+		 * case
+		 */
+		state->intervals[state->nintervals].nitems = state->nitems;
+		state->nintervals++;
+	}
+
+	/* Reset state for next interval */
+	state->nhtids = 0;
+	state->nitems = 0;
+	state->phystupsize = 0;
+
+	return nhtids;
+}
+
 /*
  * Determine if page non-pivot tuples (data items) are all duplicates of the
  * same value -- if they are, deduplication's "single value" strategy should
@@ -666,8 +1128,8 @@ _bt_form_posting(IndexTuple base, ItemPointer htids, int nhtids)
  * Generate a replacement tuple by "updating" a posting list tuple so that it
  * no longer has TIDs that need to be deleted.
  *
- * Used by VACUUM.  Caller's vacposting argument points to the existing
- * posting list tuple to be updated.
+ * Used by both VACUUM and bottom-up index deletion.  Caller's vacposting
+ * argument points to the existing posting list tuple to be updated.
  *
  * On return, caller's vacposting argument will point to final "updated"
  * tuple, which will be palloc()'d in caller's memory context.
@@ -809,6 +1271,49 @@ _bt_swap_posting(IndexTuple newitem, IndexTuple oposting, int postingoff)
 	return nposting;
 }
 
+/*
+ * Comparator used by _bt_bottomup_pass() to restore deltids array back to its
+ * original sort order
+ */
+static int
+_bt_indexdelete_cmp(const void *a, const void *b, void *arg)
+{
+	TM_IndexDelete *indexdelete1 = (TM_IndexDelete *) a;
+	TM_IndexDelete *indexdelete2 = (TM_IndexDelete *) b;
+	TM_IndexStatus *tidstatuses = (TM_IndexStatus *) arg;
+
+	OffsetNumber offset1 = (tidstatuses + indexdelete1->id)->idxoffnum;
+	OffsetNumber offset2 = (tidstatuses + indexdelete2->id)->idxoffnum;
+	ItemPointer tid1 = &indexdelete1->tid;
+	ItemPointer tid2 = &indexdelete2->tid;
+
+	if (offset1 > offset2)
+		return 1;
+	if (offset1 < offset2)
+		return -1;
+
+	/* Must be posting list tuple -- restore TID order */
+	{
+		BlockNumber blk1 = ItemPointerGetBlockNumber(tid1);
+		BlockNumber blk2 = ItemPointerGetBlockNumber(tid2);
+
+		if (blk1 != blk2)
+			return (blk1 < blk2) ? -1 : 1;
+	}
+	{
+		OffsetNumber pos1 = ItemPointerGetOffsetNumber(tid1);
+		OffsetNumber pos2 = ItemPointerGetOffsetNumber(tid2);
+
+		if (pos1 != pos2)
+			return (pos1 < pos2) ? -1 : 1;
+	}
+
+	/* ItemPointer values should never be equal */
+	Assert(false);
+
+	return 0;
+}
+
 /*
  * Verify posting list invariants for "posting", which must be a posting list
  * tuple.  Used within assertions.
diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c
index d36f7557c8..b9072b13d4 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -37,6 +37,7 @@ static TransactionId _bt_check_unique(Relation rel, BTInsertState insertstate,
 static OffsetNumber _bt_findinsertloc(Relation rel,
 									  BTInsertState insertstate,
 									  bool checkingunique,
+									  bool indexunchanged,
 									  BTStack stack,
 									  Relation heapRel);
 static void _bt_stepright(Relation rel, BTInsertState insertstate, BTStack stack);
@@ -58,7 +59,10 @@ static void _bt_insert_parent(Relation rel, Buffer buf, Buffer rbuf,
 static Buffer _bt_newroot(Relation rel, Buffer lbuf, Buffer rbuf);
 static inline bool _bt_pgaddtup(Page page, Size itemsize, IndexTuple itup,
 								OffsetNumber itup_off, bool newfirstdataitem);
-static void _bt_vacuum_one_page(Relation rel, Buffer buffer, Relation heapRel);
+static void _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
+										 BTInsertState insertstate,
+										 bool lpdeadonly, bool checkingunique,
+										 bool uniquedup, bool indexunchanged);
 
 /*
  *	_bt_doinsert() -- Handle insertion of a single index tuple in the tree.
@@ -80,7 +84,8 @@ static void _bt_vacuum_one_page(Relation rel, Buffer buffer, Relation heapRel);
  */
 bool
 _bt_doinsert(Relation rel, IndexTuple itup,
-			 IndexUniqueCheck checkUnique, Relation heapRel)
+			 IndexUniqueCheck checkUnique, Relation heapRel,
+			 bool indexunchanged)
 {
 	bool		is_unique = false;
 	BTInsertStateData insertstate;
@@ -235,7 +240,7 @@ search:
 		 * checkingunique.
 		 */
 		newitemoff = _bt_findinsertloc(rel, &insertstate, checkingunique,
-									   stack, heapRel);
+									   indexunchanged, stack, heapRel);
 		_bt_insertonpg(rel, itup_key, insertstate.buf, InvalidBuffer, stack,
 					   itup, insertstate.itemsz, newitemoff,
 					   insertstate.postingoff, false);
@@ -663,7 +668,8 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel,
 					/*
 					 * The conflicting tuple (or all HOT chains pointed to by
 					 * all posting list TIDs) is dead to everyone, so mark the
-					 * index entry killed.
+					 * index entry killed.  (Note that we only rely on the
+					 * page-level flag in !heapkeyspace indexes.)
 					 */
 					ItemIdMarkDead(curitemid);
 					opaque->btpo_flags |= BTP_HAS_GARBAGE;
@@ -774,6 +780,12 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel,
  *		room for the new tuple, this function moves right, trying to find a
  *		legal page that does.)
  *
+ *		If 'indexunchanged' is true, this is for an UPDATE that didn't
+ *		logically change the indexed value, but must nevertheless have a new
+ *		entry to point to a successor version.  This hint from the executor
+ *		will influence our behavior when the page might have to be split and
+ *		we must consider if it's avoidable.
+ *
  *		On exit, insertstate buffer contains the chosen insertion page, and
  *		the offset within that page is returned.  If _bt_findinsertloc needed
  *		to move right, the lock and pin on the original page are released, and
@@ -790,6 +802,7 @@ static OffsetNumber
 _bt_findinsertloc(Relation rel,
 				  BTInsertState insertstate,
 				  bool checkingunique,
+				  bool indexunchanged,
 				  BTStack stack,
 				  Relation heapRel)
 {
@@ -814,7 +827,7 @@ _bt_findinsertloc(Relation rel,
 	if (itup_key->heapkeyspace)
 	{
 		/* Keep track of whether checkingunique duplicate seen */
-		bool		uniquedup = false;
+		bool		uniquedup = indexunchanged;
 
 		/*
 		 * If we're inserting into a unique index, we may have to walk right
@@ -871,38 +884,15 @@ _bt_findinsertloc(Relation rel,
 		}
 
 		/*
-		 * If the target page is full, see if we can obtain enough space by
-		 * erasing LP_DEAD items.  If that fails to free enough space, see if
-		 * we can avoid a page split by performing a deduplication pass over
-		 * the page.
-		 *
-		 * We only perform a deduplication pass for a checkingunique caller
-		 * when the incoming item is a duplicate of an existing item on the
-		 * leaf page.  This heuristic avoids wasting cycles -- we only expect
-		 * to benefit from deduplicating a unique index page when most or all
-		 * recently added items are duplicates.  See nbtree/README.
+		 * If the target page is full, see if we can obtain enough space using
+		 * one or more strategies (e.g. erasing LP_DEAD items, deduplication).
+		 * Page splits are expensive, and should only go ahead when truly
+		 * necessary.
 		 */
 		if (PageGetFreeSpace(page) < insertstate->itemsz)
-		{
-			if (P_HAS_GARBAGE(lpageop))
-			{
-				_bt_vacuum_one_page(rel, insertstate->buf, heapRel);
-				insertstate->bounds_valid = false;
-
-				/* Might as well assume duplicates (if checkingunique) */
-				uniquedup = true;
-			}
-
-			if (itup_key->allequalimage && BTGetDeduplicateItems(rel) &&
-				(!checkingunique || uniquedup) &&
-				PageGetFreeSpace(page) < insertstate->itemsz)
-			{
-				_bt_dedup_one_page(rel, insertstate->buf, heapRel,
-								   insertstate->itup, insertstate->itemsz,
-								   checkingunique);
-				insertstate->bounds_valid = false;
-			}
-		}
+			_bt_delete_or_dedup_one_page(rel, heapRel, insertstate, false,
+										 checkingunique, uniquedup,
+										 indexunchanged);
 	}
 	else
 	{
@@ -942,8 +932,10 @@ _bt_findinsertloc(Relation rel,
 			 */
 			if (P_HAS_GARBAGE(lpageop))
 			{
-				_bt_vacuum_one_page(rel, insertstate->buf, heapRel);
-				insertstate->bounds_valid = false;
+				/* Erase LP_DEAD items (won't deduplicate) */
+				_bt_delete_or_dedup_one_page(rel, heapRel, insertstate, true,
+											 checkingunique, false,
+											 indexunchanged);
 
 				if (PageGetFreeSpace(page) >= insertstate->itemsz)
 					break;		/* OK, now we have enough space */
@@ -993,14 +985,17 @@ _bt_findinsertloc(Relation rel,
 		 * performing a posting list split, so delete all LP_DEAD items early.
 		 * This is the only case where LP_DEAD deletes happen even though
 		 * there is space for newitem on the page.
+		 *
+		 * This can only erase LP_DEAD items (it won't deduplicate).
 		 */
-		_bt_vacuum_one_page(rel, insertstate->buf, heapRel);
+		_bt_delete_or_dedup_one_page(rel, heapRel, insertstate, true,
+									 checkingunique, false, indexunchanged);
 
 		/*
 		 * Do new binary search.  New insert location cannot overlap with any
 		 * posting list now.
 		 */
-		insertstate->bounds_valid = false;
+		Assert(!insertstate->bounds_valid);
 		insertstate->postingoff = 0;
 		newitemoff = _bt_binsrch_insert(rel, insertstate);
 		Assert(insertstate->postingoff == 0);
@@ -2623,32 +2618,80 @@ _bt_pgaddtup(Page page,
 }
 
 /*
- * _bt_vacuum_one_page - vacuum just one index page.
+ * _bt_delete_or_dedup_one_page - Try to avoid a leaf page split by attempting
+ * a variety of operations.
  *
- * Try to remove LP_DEAD items from the given page.  The passed buffer
- * must be exclusive-locked, but unlike a real VACUUM, we don't need a
- * super-exclusive "cleanup" lock (see nbtree/README).
+ * There are three operations performed here: deleting items already marked
+ * LP_DEAD, deduplication, and bottom-up index deletion.  If all three
+ * operations fail to free enough space for the incoming item then caller will
+ * go on to split the page.  We always attempt our preferred strategy (which
+ * is to delete items whose LP_DEAD bit are set) first.  If that doesn't work
+ * out we consider alternatives.  Most calls here will not exhaustively
+ * attempt all three operations.  Deduplication and bottom-up index deletion
+ * are relatively expensive operations, so we try to pick one or the other up
+ * front (whichever one seems better for this specific page).
+ *
+ * Caller's checkingunique, uniquedup, and indexunchanged arguments help us
+ * decide which alternative strategy we should attempt (or attempt first).
+ * Deduplication is primarily useful with low cardinality data.  Bottom-up
+ * index deletion is a backstop against version churn caused by repeated
+ * UPDATE statements where affected indexes don't receive logical changes
+ * (because an optimization like heapam's HOT cannot be applied in the
+ * tableam).  But useful interplay between both techniques over time is
+ * sometimes possible.
+ *
+ * Deduplication can sometimes step in when bottom-up index deletion fails due
+ * to it simply being unsafe to delete old version tuples that accumulate on a
+ * leaf page (usually because of one old snapshot that might need the old
+ * versions, and thereby disrupts the cleanup of garbage tuples generally).
+ * Deduplication may buy time for bottom-up index deletion, which can
+ * ultimately succeed because the question of splitting a page affected by
+ * version churn is delayed long enough for the snapshot that held back
+ * deletion to go away naturally.  Note that bottom-up deletion can perform
+ * granular deletion of posting list TIDs, just like VACUUM (but unlike our
+ * preferred strategy).
+ *
+ * Callers that only want us to look for/delete LP_DEAD items can ask for that
+ * directly by passing true 'lpdeadonly' argument.
+ *
+ * Note: We used to only delete LP_DEAD items when the BTP_HAS_GARBAGE page
+ * level flag was found set.  The flag was useful back when there wasn't
+ * necessarily one single page for a duplicate tuple to go on (before heap TID
+ * became a part of the key space in version 4 indexes).  But we don't
+ * actually look at the flag anymore (it's not a gating condition for our
+ * caller).  That would cause us to miss tuples that are safe to delete,
+ * without getting any benefit in return.  We know that the alternative is to
+ * split the page; scanning the line pointer array in passing won't have
+ * noticeable overhead.  (We still maintain the BTP_HAS_GARBAGE flag despite
+ * all this because !heapkeyspace indexes must still do a "getting tired"
+ * linear search in the same way, and so are likely to still benefit.
+ * !heapkeyspace callers do in fact use the BTP_HAS_GARBAGE flag as a gating
+ * condition.)
  */
 static void
-_bt_vacuum_one_page(Relation rel, Buffer buffer, Relation heapRel)
+_bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
+							 BTInsertState insertstate,
+							 bool lpdeadonly, bool checkingunique,
+							 bool uniquedup, bool indexunchanged)
 {
 	OffsetNumber deletable[MaxIndexTuplesPerPage];
 	int			ndeletable = 0;
 	OffsetNumber offnum,
-				minoff,
 				maxoff;
+	Buffer		buffer = insertstate->buf;
+	BTScanInsert itup_key = insertstate->itup_key;
 	Page		page = BufferGetPage(buffer);
 	BTPageOpaque opaque = (BTPageOpaque) PageGetSpecialPointer(page);
 
 	Assert(P_ISLEAF(opaque));
+	Assert(lpdeadonly || itup_key->heapkeyspace);
 
 	/*
 	 * Scan over all items to see which ones need to be deleted according to
 	 * LP_DEAD flags.
 	 */
-	minoff = P_FIRSTDATAKEY(opaque);
 	maxoff = PageGetMaxOffsetNumber(page);
-	for (offnum = minoff;
+	for (offnum = P_FIRSTDATAKEY(opaque);
 		 offnum <= maxoff;
 		 offnum = OffsetNumberNext(offnum))
 	{
@@ -2659,12 +2702,74 @@ _bt_vacuum_one_page(Relation rel, Buffer buffer, Relation heapRel)
 	}
 
 	if (ndeletable > 0)
-		_bt_delitems_delete(rel, buffer, deletable, ndeletable, heapRel);
+	{
+		_bt_delitems_delete(rel, buffer, false, InvalidTransactionId,
+							deletable, ndeletable, NULL, 0, heapRel);
+		insertstate->bounds_valid = false;
+
+		/* Return when a page split has already been avoided */
+		if (PageGetFreeSpace(page) >= insertstate->itemsz)
+			return;
+
+		/* Might as well assume duplicates (if checkingunique) */
+		uniquedup = true;
+	}
 
 	/*
-	 * Note: if we didn't find any LP_DEAD items, then the page's
-	 * BTP_HAS_GARBAGE hint bit is falsely set.  We do not bother expending a
-	 * separate write to clear it, however.  We will clear it when we split
-	 * the page, or when deduplication runs.
+	 * Some callers only want to delete LP_DEAD items.  Return early for these
+	 * callers.
+	 *
+	 * Note: The page's BTP_HAS_GARBAGE hint flag may still be set when we
+	 * return at this point (or when we go on the try either or both of our
+	 * other strategies and they also fail).  We do not bother expending a
+	 * separate write to clear it, however.  Caller will definitely clear it
+	 * when it goes on to split the page (plus deduplication knows to clear
+	 * the flag when it actually modifies the page).
 	 */
+	if (lpdeadonly)
+		return;
+
+	/*
+	 * We can get called in the checkingunique case when there is no reason to
+	 * believe that there are any duplicates on the page; we should at least
+	 * still check for LP_DEAD items.  Now that we have, and now that it has
+	 * not helped, give up and let caller split the page.
+	 *
+	 * We give up because the other types of operations that might avoid a
+	 * page split are also unlikely to work out, but are much more expensive
+	 * to try.  That cannot be justified given there is no reason to think
+	 * that there are duplicates that we can target.
+	 */
+	if (checkingunique && !uniquedup)
+		return;
+
+	/* Assume bounds about to be invalidated (this is almost certain now) */
+	insertstate->bounds_valid = false;
+
+	/*
+	 * Perform bottom-up index deletion pass when executor hint indicated that
+	 * incoming item is logically unchanged, or for a unique index that is
+	 * known to have physical duplicates for some other reason.  (There is a
+	 * large overlap between these two cases for a unique index.  It's worth
+	 * having both triggering conditions in order to apply the optimization in
+	 * the event of successive related INSERT and DELETE statements.)
+	 *
+	 * We'll go on to do a deduplication pass when a bottom-up pass either
+	 * fails to delete an acceptable amount of free space (a non-trivial
+	 * fraction of the page that typically exceeds the new item's size), or
+	 * when we're dealing with low cardinality data that has relatively few
+	 * tuples (with large posting lists).
+	 */
+	if ((indexunchanged || uniquedup) &&
+		_bt_bottomup_pass(rel, buffer, heapRel, insertstate->itemsz,
+						  checkingunique))
+		return;
+
+	/*
+	 * Perform deduplication pass, though only when it is enabled for the
+	 * index and known to be safe (it must be an allequalimage index).
+	 */
+	if (BTGetDeduplicateItems(rel) && itup_key->allequalimage)
+		_bt_dedup_pass(rel, buffer, heapRel, insertstate->itup,
+					   insertstate->itemsz, checkingunique);
 }
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 7f392480ac..9a72de23a5 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -1110,15 +1110,14 @@ _bt_page_recyclable(Page page)
  * sorted in ascending order.
  *
  * Routine deals with deleting TIDs when some (but not all) of the heap TIDs
- * in an existing posting list item are to be removed by VACUUM.  This works
- * by updating/overwriting an existing item with caller's new version of the
- * item (a version that lacks the TIDs that are to be deleted).
+ * in an existing posting list item are to be removed.  This works by
+ * updating/overwriting an existing item with caller's new version of the item
+ * (a version that lacks the TIDs that are to be deleted).
  *
  * We record VACUUMs and b-tree deletes differently in WAL.  Deletes must
  * generate their own latestRemovedXid by accessing the heap directly, whereas
  * VACUUMs rely on the initial heap scan taking care of it indirectly.  Also,
- * only VACUUM can perform granular deletes of individual TIDs in posting list
- * tuples.
+ * we remove the VACUUM cycle ID from pages, which b-tree deletes don't do.
  */
 void
 _bt_delitems_vacuum(Relation rel, Buffer buf,
@@ -1187,8 +1186,15 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
 	 * array of offset numbers.
 	 *
 	 * PageIndexTupleOverwrite() won't unset each item's LP_DEAD bit when it
-	 * happens to already be set.  Although we unset the BTP_HAS_GARBAGE page
-	 * level flag, unsetting individual LP_DEAD bits should still be avoided.
+	 * happens to already be set.  It's important that we not interfere with
+	 * garbage collection mechanisms that use _bt_delitems_delete().
+	 *
+	 * Eagerly removing items with their LP_DEAD bit set seems unwise, because
+	 * in practice bottom-up techniques do a good job of taking care of the
+	 * problem at a rate that makes sense at a keyspace-local level.  Plus
+	 * it'd just be messy.  We'd have to explicitly log a latestRemovedXid
+	 * cutoff, just like _bt_delitems_delete().  Accessing tableam blocks
+	 * again from here is rather unappealing.
 	 */
 	for (int i = 0; i < nupdatable; i++)
 	{
@@ -1215,20 +1221,12 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
 	opaque->btpo_cycleid = 0;
 
 	/*
-	 * Mark the page as not containing any LP_DEAD items.  This is not
-	 * certainly true (there might be some that have recently been marked, but
-	 * weren't targeted by VACUUM's heap scan), but it will be true often
-	 * enough.  VACUUM does not delete items purely because they have their
-	 * LP_DEAD bit set, since doing so would necessitate explicitly logging a
-	 * latestRemovedXid cutoff (this is how _bt_delitems_delete works).
+	 * Clear the BTP_HAS_GARBAGE page flag.
 	 *
-	 * The consequences of falsely unsetting BTP_HAS_GARBAGE should be fairly
-	 * limited, since we never falsely unset an LP_DEAD bit.  Workloads that
-	 * are particularly dependent on LP_DEAD bits being set quickly will
-	 * usually manage to set the BTP_HAS_GARBAGE flag before the page fills up
-	 * again anyway.  Furthermore, attempting a deduplication pass will remove
-	 * all LP_DEAD items, regardless of whether the BTP_HAS_GARBAGE hint bit
-	 * is set or not.
+	 * This flag indicates the presence of LP_DEAD items on the page (though
+	 * not reliably).  We only use it with pg_upgrade'd !heapkeyspace indexes.
+	 * (Otherwise we check all of the line pointers directly.  The cost of
+	 * doing it that way is negligible compared to splitting the page.)
 	 */
 	opaque->btpo_flags &= ~BTP_HAS_GARBAGE;
 
@@ -1277,43 +1275,134 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
  * Delete item(s) from a btree leaf page during single-page cleanup.
  *
  * This routine assumes that the caller has pinned and write locked the
- * buffer.  Also, the given deletable array *must* be sorted in ascending
- * order.
+ * buffer.  Also, the given deletable and updatable arrays *must* be sorted in
+ * ascending order.
+ *
+ * Routine deals with deleting TIDs when some (but not all) of the heap TIDs
+ * in an existing posting list item are to be removed.  This works by
+ * updating/overwriting an existing item with caller's new version of the item
+ * (a version that lacks the TIDs that are to be deleted).
  *
  * This is nearly the same as _bt_delitems_vacuum as far as what it does to
  * the page, but it needs to generate its own latestRemovedXid by accessing
  * the heap.  This is used by the REDO routine to generate recovery conflicts.
- * Also, it doesn't handle posting list tuples unless the entire tuple can be
- * deleted as a whole (since there is only one LP_DEAD bit per line pointer).
+ * Though note that bottom-up index deletion caller will provide its own
+ * latestRemovedXid, since it's convenient for it to determine that at the
+ * same point that it determines that the items are dead (it won't set LP_DEAD
+ * items on leaf page at all).  Also, we don't clear page's VACUUM cycle ID.
  */
 void
 _bt_delitems_delete(Relation rel, Buffer buf,
+					bool bottomup, TransactionId bottomupXid,
 					OffsetNumber *deletable, int ndeletable,
+					BTVacuumPosting *updatable, int nupdatable,
 					Relation heapRel)
 {
 	Page		page = BufferGetPage(buf);
 	BTPageOpaque opaque;
 	TransactionId latestRemovedXid = InvalidTransactionId;
+	Size		itemsz;
+	char	   *updatedbuf = NULL;
+	Size		updatedbuflen = 0;
+	OffsetNumber updatedoffsets[MaxIndexTuplesPerPage];
 
 	/* Shouldn't be called unless there's something to do */
-	Assert(ndeletable > 0);
+	Assert(ndeletable > 0 || nupdatable > 0);
+	/* Shouldn't update posting lists unless it's for bottom-up caller */
+	Assert(nupdatable == 0 || bottomup);
 
 	if (XLogStandbyInfoActive() && RelationNeedsWAL(rel))
-		latestRemovedXid =
-			_bt_xid_horizon(rel, heapRel, page, deletable, ndeletable);
+	{
+		if (!bottomup)
+			latestRemovedXid =
+				_bt_xid_horizon(rel, heapRel, page, deletable,
+								ndeletable);
+		else
+			latestRemovedXid = bottomupXid;
+	}
+
+	for (int i = 0; i < nupdatable; i++)
+	{
+		/* Replace work area IndexTuple with updated version */
+		_bt_update_posting(updatable[i]);
+
+		/* Maintain array of updatable page offsets for WAL record */
+		updatedoffsets[i] = updatable[i]->updatedoffset;
+	}
+
+	/* XLOG stuff -- allocate and fill buffer before critical section */
+	if (nupdatable > 0 && RelationNeedsWAL(rel))
+	{
+		Size		offset = 0;
+
+		for (int i = 0; i < nupdatable; i++)
+		{
+			BTVacuumPosting vacposting = updatable[i];
+
+			itemsz = SizeOfBtreeUpdate +
+				vacposting->ndeletedtids * sizeof(uint16);
+			updatedbuflen += itemsz;
+		}
+
+		updatedbuf = palloc(updatedbuflen);
+		for (int i = 0; i < nupdatable; i++)
+		{
+			BTVacuumPosting vacposting = updatable[i];
+			xl_btree_update update;
+
+			update.ndeletedtids = vacposting->ndeletedtids;
+			memcpy(updatedbuf + offset, &update.ndeletedtids,
+				   SizeOfBtreeUpdate);
+			offset += SizeOfBtreeUpdate;
+
+			itemsz = update.ndeletedtids * sizeof(uint16);
+			memcpy(updatedbuf + offset, vacposting->deletetids, itemsz);
+			offset += itemsz;
+		}
+	}
 
 	/* No ereport(ERROR) until changes are logged */
 	START_CRIT_SECTION();
 
-	/* Fix the page */
-	PageIndexMultiDelete(page, deletable, ndeletable);
+	/*
+	 * Handle posting tuple updates.
+	 *
+	 * Deliberately do this before handling simple deletes.  If we did it the
+	 * other way around (i.e. WAL record order -- simple deletes before
+	 * updates) then we'd have to make compensating changes to the 'updatable'
+	 * array of offset numbers.
+	 */
+	for (int i = 0; i < nupdatable; i++)
+	{
+		OffsetNumber updatedoffset = updatedoffsets[i];
+		IndexTuple	itup;
+
+		itup = updatable[i]->itup;
+		itemsz = MAXALIGN(IndexTupleSize(itup));
+		if (!PageIndexTupleOverwrite(page, updatedoffset, (Item) itup,
+									 itemsz))
+			elog(PANIC, "failed to update partially dead item in block %u of index \"%s\"",
+				 BufferGetBlockNumber(buf), RelationGetRelationName(rel));
+	}
+
+	/* Now handle simple deletes of entire tuples */
+	if (ndeletable > 0)
+		PageIndexMultiDelete(page, deletable, ndeletable);
 
 	/*
 	 * Unlike _bt_delitems_vacuum, we *must not* clear the vacuum cycle ID,
-	 * because this is not called by VACUUM.  Just clear the BTP_HAS_GARBAGE
-	 * page flag, since we deleted all items with their LP_DEAD bit set.
+	 * because this is not called by VACUUM
 	 */
 	opaque = (BTPageOpaque) PageGetSpecialPointer(page);
+
+	/*
+	 * Clear the BTP_HAS_GARBAGE page flag.
+	 *
+	 * This flag indicates the presence of LP_DEAD items on the page (though
+	 * not reliably).  We only use it with pg_upgrade'd !heapkeyspace indexes.
+	 * (Otherwise we check all of the line pointers directly.  The cost of
+	 * doing it that way is negligible compared to splitting the page.)
+	 */
 	opaque->btpo_flags &= ~BTP_HAS_GARBAGE;
 
 	MarkBufferDirty(buf);
@@ -1326,6 +1415,7 @@ _bt_delitems_delete(Relation rel, Buffer buf,
 
 		xlrec_delete.latestRemovedXid = latestRemovedXid;
 		xlrec_delete.ndeleted = ndeletable;
+		xlrec_delete.nupdated = nupdatable;
 
 		XLogBeginInsert();
 		XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
@@ -1336,8 +1426,16 @@ _bt_delitems_delete(Relation rel, Buffer buf,
 		 * When XLogInsert stores the whole buffer, the array need not be
 		 * stored too.
 		 */
-		XLogRegisterBufData(0, (char *) deletable,
-							ndeletable * sizeof(OffsetNumber));
+		if (ndeletable > 0)
+			XLogRegisterBufData(0, (char *) deletable,
+								ndeletable * sizeof(OffsetNumber));
+
+		if (nupdatable > 0)
+		{
+			XLogRegisterBufData(0, (char *) updatedoffsets,
+								nupdatable * sizeof(OffsetNumber));
+			XLogRegisterBufData(0, updatedbuf, updatedbuflen);
+		}
 
 		recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DELETE);
 
@@ -1345,6 +1443,13 @@ _bt_delitems_delete(Relation rel, Buffer buf,
 	}
 
 	END_CRIT_SECTION();
+
+	/* can't leak memory here */
+	if (updatedbuf != NULL)
+		pfree(updatedbuf);
+	/* free tuples generated by calling _bt_update_posting() */
+	for (int i = 0; i < nupdatable; i++)
+		pfree(updatable[i]->itup);
 }
 
 /*
diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c
index 8eeb7bb64e..bc82cd4e7d 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -209,7 +209,7 @@ btinsert(Relation rel, Datum *values, bool *isnull,
 	itup = index_form_tuple(RelationGetDescr(rel), values, isnull);
 	itup->t_tid = *ht_ctid;
 
-	result = _bt_doinsert(rel, itup, checkUnique, heapRel);
+	result = _bt_doinsert(rel, itup, checkUnique, heapRel, indexunchanged);
 
 	pfree(itup);
 
diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c
index 81589b9056..2f5f14e527 100644
--- a/src/backend/access/nbtree/nbtutils.c
+++ b/src/backend/access/nbtree/nbtutils.c
@@ -1877,7 +1877,8 @@ _bt_killitems(IndexScanDesc scan)
 	 * Since this can be redone later if needed, mark as dirty hint.
 	 *
 	 * Whenever we mark anything LP_DEAD, we also set the page's
-	 * BTP_HAS_GARBAGE flag, which is likewise just a hint.
+	 * BTP_HAS_GARBAGE flag, which is likewise just a hint.  (Note that we
+	 * only rely on the page-level flag in !heapkeyspace indexes.)
 	 */
 	if (killedsomething)
 	{
diff --git a/src/backend/access/nbtree/nbtxlog.c b/src/backend/access/nbtree/nbtxlog.c
index e913226760..d2142f3e89 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -675,7 +675,56 @@ btree_xlog_delete(XLogReaderState *record)
 
 		page = (Page) BufferGetPage(buffer);
 
-		PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted);
+		if (xlrec->nupdated > 0)
+		{
+			OffsetNumber *updatedoffsets;
+			xl_btree_update *updates;
+
+			updatedoffsets = (OffsetNumber *)
+				(ptr + xlrec->ndeleted * sizeof(OffsetNumber));
+			updates = (xl_btree_update *) ((char *) updatedoffsets +
+										   xlrec->nupdated *
+										   sizeof(OffsetNumber));
+
+			for (int i = 0; i < xlrec->nupdated; i++)
+			{
+				BTVacuumPosting vacposting;
+				IndexTuple	origtuple;
+				ItemId		itemid;
+				Size		itemsz;
+
+				itemid = PageGetItemId(page, updatedoffsets[i]);
+				origtuple = (IndexTuple) PageGetItem(page, itemid);
+
+				vacposting = palloc(offsetof(BTVacuumPostingData, deletetids) +
+									updates->ndeletedtids * sizeof(uint16));
+				vacposting->updatedoffset = updatedoffsets[i];
+				vacposting->itup = origtuple;
+				vacposting->ndeletedtids = updates->ndeletedtids;
+				memcpy(vacposting->deletetids,
+					   (char *) updates + SizeOfBtreeUpdate,
+					   updates->ndeletedtids * sizeof(uint16));
+
+				_bt_update_posting(vacposting);
+
+				/* Overwrite updated version of tuple */
+				itemsz = MAXALIGN(IndexTupleSize(vacposting->itup));
+				if (!PageIndexTupleOverwrite(page, updatedoffsets[i],
+											 (Item) vacposting->itup, itemsz))
+					elog(PANIC, "failed to update partially dead item");
+
+				pfree(vacposting->itup);
+				pfree(vacposting);
+
+				/* advance to next xl_btree_update from array */
+				updates = (xl_btree_update *)
+					((char *) updates + SizeOfBtreeUpdate +
+					 updates->ndeletedtids * sizeof(uint16));
+			}
+		}
+
+		if (xlrec->ndeleted > 0)
+			PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted);
 
 		/* Mark the page as not containing any LP_DEAD items */
 		opaque = (BTPageOpaque) PageGetSpecialPointer(page);
@@ -1065,7 +1114,8 @@ btree_mask(char *pagedata, BlockNumber blkno)
 
 	/*
 	 * BTP_HAS_GARBAGE is just an un-logged hint bit. So, mask it. See
-	 * _bt_killitems(), _bt_check_unique() for details.
+	 * _bt_delete_or_dedup_one_page(), _bt_killitems(), and _bt_check_unique()
+	 * for details.
 	 */
 	maskopaq->btpo_flags &= ~BTP_HAS_GARBAGE;
 
-- 
2.25.1

