From 24825f6dae21f24b6ce564a59ffadfad7433b79c Mon Sep 17 00:00:00 2001
Message-ID: <24825f6dae21f24b6ce564a59ffadfad7433b79c.1778237699.git.james.locke.uk@gmail.com>
In-Reply-To: <cover.1778237699.git.james.locke.uk@gmail.com>
References: <CAA-aLv6sYZ5XnuYrytTjxZumBh3KrdyMRmasxHfgaKf-HJrNpw@mail.gmail.com>
	<cover.1778237699.git.james.locke.uk@gmail.com>
From: James Lock <james.locke.uk@gmail.com>
Date: Thu, 7 May 2026 14:09:19 +0100
Subject: [POC PATCH 2/5] heap: Add heap_relocate primitive

heap_relocate moves a live tuple from its current page to a caller-
specified target page.  Logically it is a no-op update: the new tuple
is byte-identical to the source.  Compared to heap_update it skips
HOT consideration (the target is by construction a different page),
modified-attribute analysis, toasting, and replica-identity old-key
extraction.

Concurrency policy is deliberately conservative for this first cut:
heap_relocate refuses any tuple whose xmax is not provably invalid.
That rules out live lockers, multixacts, and tuples whose xmax has
been set but not yet hint-bit-resolved.  The caller (an upcoming
VACUUM-time compaction loop) handles those by skipping and revisiting
on a later run.  Unlike heap_update, encountering an invisible tuple
returns TM_Invisible quietly rather than ereport()ing -- a maintenance
command iterating across pages can legitimately see in-progress xmins.

A new flag XLH_UPDATE_RELOCATED is added on the existing xl_heap_update
WAL record.  Replay treats relocations as ordinary updates, but logical
decoding's DecodeUpdate filters them out so subscribers do not see
phantom no-op UPDATE events.  log_heap_update grows an is_relocation
parameter that sets the flag.

This commit only adds the primitive; no caller exists yet.
---
 src/backend/access/heap/heapam.c         | 439 ++++++++++++++++++++++-
 src/backend/replication/logical/decode.c |   9 +
 src/include/access/heapam.h              |   7 +
 src/include/access/heapam_xlog.h         |   7 +
 4 files changed, 459 insertions(+), 3 deletions(-)

diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index abfd8e8970a..634220e2c44 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -63,7 +63,7 @@ static XLogRecPtr log_heap_update(Relation reln, Buffer oldbuf,
 								  Buffer newbuf, HeapTuple oldtup,
 								  HeapTuple newtup, HeapTuple old_key_tuple,
 								  bool all_visible_cleared, bool new_all_visible_cleared,
-								  bool walLogical);
+								  bool walLogical, bool is_relocation);
 #ifdef USE_ASSERT_CHECKING
 static void check_lock_if_inplace_updateable_rel(Relation relation,
 												 const ItemPointerData *otid,
@@ -4099,7 +4099,7 @@ l2:
 								 old_key_tuple,
 								 all_visible_cleared,
 								 all_visible_cleared_new,
-								 walLogical);
+								 walLogical, false);
 		if (newbuf != buffer)
 		{
 			PageSetLSN(newpage, recptr);
@@ -4179,6 +4179,437 @@ l2:
 	return TM_Ok;
 }
 
+/*
+ *	heap_relocate	- move a live tuple to a different heap page
+ *
+ *	Logically a no-op update: the new tuple is byte-identical to the source.
+ *	Used by VACUUM (COMPACT) to drain tuples out of high-numbered pages so
+ *	the relation can be truncated.  Unlike heap_update:
+ *	  - The target page is supplied by the caller (no FSM lookup, no
+ *		relation extension).  If the target page no longer has room when we
+ *		acquire its buffer lock, we return TM_BeingModified and leave the
+ *		source tuple untouched, so the caller can pick a different target.
+ *	  - HOT is suppressed by construction (the target page is never the
+ *		source page).
+ *	  - We do not toast: the tuple body is unchanged, including any
+ *		pre-existing external (toast) datum pointers.
+ *	  - Modified-attribute analysis, replica identity extraction, and HOT
+ *		attribute bitmaps are all skipped; logical decoding filters our WAL
+ *		records via XLH_UPDATE_RELOCATED.
+ *	  - We do not wait for concurrent lockers.  We relocate tuples that
+ *		have only key-share lockers (single-locker or multixact), preserving
+ *		those lockers on the new tuple's xmax so concurrent locking xacts
+ *		still find their lock on the relocated row.  Tuples being updated
+ *		by another live transaction, or held under a stronger lock that
+ *		would conflict with our (no-key-exclusive) update, are skipped
+ *		via TM_BeingModified so the orchestrator can retry them later.
+ *
+ *	Caller must hold at least RowExclusiveLock on the relation.  The
+ *	relation must use the heap table AM.  target_block must differ from
+ *	the source block and must be within the relation.
+ *
+ *	Possible TM_Result values:
+ *	  TM_Ok				- tuple was relocated; *update_indexes set to TU_All
+ *	  TM_BeingModified	- skipped because of concurrent activity OR because
+ *						  the target page no longer had room.  In both cases
+ *						  tmfd->ctid is set to the source TID so the caller
+ *						  can retry.
+ *	  TM_Updated		- source has been updated by a committed concurrent
+ *	  TM_Deleted		  txn (caller must adjust); see heap_update.
+ *	  TM_Invisible		- source is not LP_NORMAL or otherwise unreadable
+ *						  (raises ERROR like heap_update does).
+ */
+TM_Result
+heap_relocate(Relation relation, const ItemPointerData *otid,
+			  BlockNumber target_block, CommandId cid,
+			  TM_FailureData *tmfd, TU_UpdateIndexes *update_indexes,
+			  ItemPointer new_tid)
+{
+	TM_Result	result;
+	TransactionId xid = GetCurrentTransactionId();
+	ItemId		lp;
+	HeapTupleData oldtup;
+	HeapTuple	newtup;
+	Page		page,
+				newpage;
+	BlockNumber block;
+	Buffer		buffer,
+				newbuf,
+				vmbuffer = InvalidBuffer,
+				vmbuffer_new = InvalidBuffer;
+	bool		all_visible_cleared = false;
+	bool		all_visible_cleared_new = false;
+	TransactionId xmax_old_tuple,
+				xmax_new_tuple;
+	uint16		infomask_old_tuple,
+				infomask2_old_tuple,
+				infomask_new_tuple,
+				infomask2_new_tuple;
+	bool		checked_lockers = false;
+	bool		locker_remains = false;
+	bool		iscombo;
+	const LockTupleMode lockmode = LockTupleNoKeyExclusive;
+
+	Assert(ItemPointerIsValid(otid));
+	Assert(BlockNumberIsValid(target_block));
+
+	if (IsInParallelMode())
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_TRANSACTION_STATE),
+				 errmsg("cannot relocate tuples during a parallel operation")));
+
+	block = ItemPointerGetBlockNumber(otid);
+	if (target_block == block)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("relocation target page must differ from source page")));
+
+	buffer = ReadBuffer(relation, block);
+	page = BufferGetPage(buffer);
+
+	if (PageIsAllVisible(page))
+		visibilitymap_pin(relation, block, &vmbuffer);
+
+	LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE);
+
+	lp = PageGetItemId(page, ItemPointerGetOffsetNumber(otid));
+
+	if (!ItemIdIsNormal(lp))
+	{
+		UnlockReleaseBuffer(buffer);
+		if (vmbuffer != InvalidBuffer)
+			ReleaseBuffer(vmbuffer);
+		tmfd->ctid = *otid;
+		tmfd->xmax = InvalidTransactionId;
+		tmfd->cmax = InvalidCommandId;
+		*update_indexes = TU_None;
+		return TM_Deleted;
+	}
+
+	oldtup.t_tableOid = RelationGetRelid(relation);
+	oldtup.t_data = (HeapTupleHeader) PageGetItem(page, lp);
+	oldtup.t_len = ItemIdGetLength(lp);
+	oldtup.t_self = *otid;
+
+	/*
+	 * If we end up preserving an existing locker on the new tuple's xmax we
+	 * may have to write a multixact, so set the per-backend
+	 * OldestMemberMXactId now.
+	 */
+	MultiXactIdSetOldestMember();
+
+	/*
+	 * Check the source tuple's update visibility.  TM_Ok means we can
+	 * relocate immediately; TM_BeingModified means a concurrent locker or
+	 * updater is touching the tuple and we have to decide whether to skip
+	 * (waiting on a conflicting locker / live updater) or proceed
+	 * (preserving the locker on the new tuple's xmax).  Anything else is
+	 * propagated to the caller as-is.
+	 */
+	result = HeapTupleSatisfiesUpdate(&oldtup, cid, buffer);
+
+	if (result == TM_BeingModified)
+	{
+		TransactionId xwait;
+		uint16		infomask;
+		bool		can_continue = false;
+
+		xwait = HeapTupleHeaderGetRawXmax(oldtup.t_data);
+		infomask = oldtup.t_data->t_infomask;
+
+		if (infomask & HEAP_XMAX_IS_MULTI)
+		{
+			bool		current_is_member = false;
+
+			if (!DoesMultiXactIdConflict((MultiXactId) xwait, infomask,
+										 lockmode, &current_is_member))
+			{
+				TransactionId update_xact;
+
+				/*
+				 * No member of the multixact conflicts with our update.  If
+				 * a member is itself an updater (not lock-only) and is
+				 * still live, we still cannot proceed; otherwise we can.
+				 */
+				if (!HEAP_XMAX_IS_LOCKED_ONLY(infomask))
+					update_xact = HeapTupleGetUpdateXid(oldtup.t_data);
+				else
+					update_xact = InvalidTransactionId;
+
+				if (!TransactionIdIsValid(update_xact) ||
+					TransactionIdDidAbort(update_xact))
+				{
+					checked_lockers = true;
+					locker_remains = true;
+					can_continue = true;
+				}
+			}
+		}
+		else if (TransactionIdIsCurrentTransactionId(xwait))
+		{
+			/*
+			 * The tuple is locked by our own transaction.  This shouldn't
+			 * normally happen for vacuum-driven relocation; treat it as a
+			 * skip rather than risk corrupting our own lock state.
+			 */
+		}
+		else if (HEAP_XMAX_IS_KEYSHR_LOCKED(infomask))
+		{
+			/*
+			 * A single key-share locker on another transaction.  Since the
+			 * relocation does not change any key column (the data is
+			 * byte-identical), the locker need not be invalidated; we
+			 * preserve it on the new tuple's xmax and proceed without
+			 * waiting.
+			 */
+			checked_lockers = true;
+			locker_remains = true;
+			can_continue = true;
+		}
+		/* else: regular updater or stronger lock -- skip, let caller retry */
+
+		if (can_continue)
+			result = TM_Ok;
+	}
+
+	if (result != TM_Ok)
+	{
+		tmfd->ctid = oldtup.t_data->t_ctid;
+		tmfd->xmax = HeapTupleHeaderGetUpdateXid(oldtup.t_data);
+		tmfd->cmax = InvalidCommandId;
+		UnlockReleaseBuffer(buffer);
+		if (vmbuffer != InvalidBuffer)
+			ReleaseBuffer(vmbuffer);
+		*update_indexes = TU_None;
+		/*
+		 * Unlike heap_update, we do not raise ERROR on TM_Invisible: a
+		 * maintenance command iterating across pages can legitimately
+		 * encounter tuples whose xmin is from an in-progress transaction.
+		 * Leave the policy decision to the caller (which will simply skip
+		 * and continue).
+		 */
+		return result;
+	}
+
+	/*
+	 * Re-check VM pin: if the page became all-visible while we acquired the
+	 * lock, pin the VM page and retake the buffer lock.  Mirrors heap_update.
+	 *
+	 * The tuple's lock state could in principle have changed during the brief
+	 * unlock window, but pruning needs cleanup lock (which we hold pin
+	 * against) and we have already decided whether to proceed based on the
+	 * pre-unlock snapshot of xmax.  Any locker that arrived since then will
+	 * see the relocation in flight and either skip or wait, just as for
+	 * heap_update.
+	 */
+	if (vmbuffer == InvalidBuffer && PageIsAllVisible(page))
+	{
+		LockBuffer(buffer, BUFFER_LOCK_UNLOCK);
+		visibilitymap_pin(relation, block, &vmbuffer);
+		LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE);
+	}
+
+	/*
+	 * The buffer-lock dance with the target page must be done with the source
+	 * page's lock released, in block-number order.  Drop the source lock,
+	 * keeping the pin, and let RelationGetSpecificBufferForTuple retake both.
+	 */
+	LockBuffer(buffer, BUFFER_LOCK_UNLOCK);
+
+	newbuf = RelationGetSpecificBufferForTuple(relation, oldtup.t_len,
+											   target_block, buffer,
+											   &vmbuffer_new, &vmbuffer);
+
+	if (newbuf == InvalidBuffer)
+	{
+		/*
+		 * Target page filled up between FSM consultation and now.  Surface
+		 * this as TM_BeingModified so the caller picks a different target.
+		 */
+		tmfd->ctid = *otid;
+		tmfd->xmax = InvalidTransactionId;
+		tmfd->cmax = InvalidCommandId;
+		ReleaseBuffer(buffer);
+		if (vmbuffer != InvalidBuffer)
+			ReleaseBuffer(vmbuffer);
+		if (vmbuffer_new != InvalidBuffer)
+			ReleaseBuffer(vmbuffer_new);
+		*update_indexes = TU_None;
+		return TM_BeingModified;
+	}
+
+	newpage = BufferGetPage(newbuf);
+
+	/*
+	 * Re-fetch the source line pointer; we re-acquired the buffer lock via
+	 * RelationGetSpecificBufferForTuple's locking-order dance, so the
+	 * pointer-derived addresses we held earlier may have moved if pruning
+	 * ran -- but pruning needs cleanup lock, which we hold pin against, so
+	 * in practice oldtup.t_data still points at our row.  Re-derive
+	 * defensively.
+	 */
+	lp = PageGetItemId(page, ItemPointerGetOffsetNumber(otid));
+	Assert(ItemIdIsNormal(lp));
+	oldtup.t_data = (HeapTupleHeader) PageGetItem(page, lp);
+	oldtup.t_len = ItemIdGetLength(lp);
+
+	/*
+	 * Compute xmax / infomasks for the source tuple post-relocation.  This
+	 * folds the relocator's xid in with any remaining lockers (potentially
+	 * creating a multixact), the same way heap_update does.
+	 */
+	compute_new_xmax_infomask(HeapTupleHeaderGetRawXmax(oldtup.t_data),
+							  oldtup.t_data->t_infomask,
+							  oldtup.t_data->t_infomask2,
+							  xid, lockmode, true,
+							  &xmax_old_tuple, &infomask_old_tuple,
+							  &infomask2_old_tuple);
+
+	/*
+	 * Compute xmax / infomasks for the NEW tuple, preserving any lockers we
+	 * found on the old tuple so concurrent locking transactions still find
+	 * their lock on the relocated row.  Logic mirrors heap_update.
+	 */
+	if ((oldtup.t_data->t_infomask & HEAP_XMAX_INVALID) ||
+		HEAP_LOCKED_UPGRADED(oldtup.t_data->t_infomask) ||
+		(checked_lockers && !locker_remains))
+		xmax_new_tuple = InvalidTransactionId;
+	else
+		xmax_new_tuple = HeapTupleHeaderGetRawXmax(oldtup.t_data);
+
+	if (!TransactionIdIsValid(xmax_new_tuple))
+	{
+		infomask_new_tuple = HEAP_XMAX_INVALID;
+		infomask2_new_tuple = 0;
+	}
+	else if (oldtup.t_data->t_infomask & HEAP_XMAX_IS_MULTI)
+	{
+		GetMultiXactIdHintBits(xmax_new_tuple, &infomask_new_tuple,
+							   &infomask2_new_tuple);
+	}
+	else
+	{
+		infomask_new_tuple = HEAP_XMAX_KEYSHR_LOCK | HEAP_XMAX_LOCK_ONLY;
+		infomask2_new_tuple = 0;
+	}
+
+	/*
+	 * Build the relocated tuple.  Header bytes are byte-identical except for
+	 * the xact fields, which we reset for the new MVCC version.  Both header
+	 * and body are copied as one block.
+	 */
+	newtup = (HeapTuple) palloc(HEAPTUPLESIZE + oldtup.t_len);
+	newtup->t_len = oldtup.t_len;
+	newtup->t_tableOid = RelationGetRelid(relation);
+	ItemPointerSetInvalid(&newtup->t_self);
+	newtup->t_data = (HeapTupleHeader) ((char *) newtup + HEAPTUPLESIZE);
+	memcpy(newtup->t_data, oldtup.t_data, oldtup.t_len);
+
+	newtup->t_data->t_infomask &= ~(HEAP_XACT_MASK);
+	newtup->t_data->t_infomask2 &= ~(HEAP2_XACT_MASK);
+	newtup->t_data->t_infomask |= HEAP_UPDATED | infomask_new_tuple;
+	newtup->t_data->t_infomask2 |= infomask2_new_tuple;
+	HeapTupleHeaderSetXmin(newtup->t_data, xid);
+	HeapTupleHeaderSetCmin(newtup->t_data, cid);
+	HeapTupleHeaderSetXmax(newtup->t_data, xmax_new_tuple);
+
+	HeapTupleHeaderAdjustCmax(oldtup.t_data, &cid, &iscombo);
+
+	/*
+	 * Predicate-lock check.  A relocation is logically a no-op for any
+	 * predicate-matching reader, but we still take the standard write-side
+	 * conflict check; false-positive SSI aborts during a maintenance
+	 * compaction are acceptable.
+	 */
+	CheckForSerializableConflictIn(relation, &oldtup.t_self,
+								   BufferGetBlockNumber(buffer));
+
+	START_CRIT_SECTION();
+
+	PageSetPrunable(page, xid);
+	PageSetFull(page);
+	PageSetPrunable(newpage, xid);
+
+	HeapTupleClearHotUpdated(&oldtup);
+	HeapTupleClearHeapOnly(newtup);
+
+	RelationPutHeapTuple(relation, newbuf, newtup, false);
+
+	/* Update the source tuple to point at the relocated copy. */
+	oldtup.t_data->t_infomask &= ~(HEAP_XMAX_BITS | HEAP_MOVED);
+	oldtup.t_data->t_infomask2 &= ~HEAP_KEYS_UPDATED;
+	HeapTupleHeaderSetXmax(oldtup.t_data, xmax_old_tuple);
+	oldtup.t_data->t_infomask |= infomask_old_tuple;
+	oldtup.t_data->t_infomask2 |= infomask2_old_tuple;
+	HeapTupleHeaderSetCmax(oldtup.t_data, cid, iscombo);
+	oldtup.t_data->t_ctid = newtup->t_self;
+
+	if (PageIsAllVisible(page))
+	{
+		all_visible_cleared = true;
+		PageClearAllVisible(page);
+		visibilitymap_clear(relation, BufferGetBlockNumber(buffer),
+							vmbuffer, VISIBILITYMAP_VALID_BITS);
+	}
+	if (PageIsAllVisible(newpage))
+	{
+		all_visible_cleared_new = true;
+		PageClearAllVisible(newpage);
+		visibilitymap_clear(relation, BufferGetBlockNumber(newbuf),
+							vmbuffer_new, VISIBILITYMAP_VALID_BITS);
+	}
+
+	MarkBufferDirty(newbuf);
+	MarkBufferDirty(buffer);
+
+	if (RelationNeedsWAL(relation))
+	{
+		XLogRecPtr	recptr;
+
+		/*
+		 * walLogical = false: relocations do not contribute to logical
+		 * decoding (decode.c filters them via XLH_UPDATE_RELOCATED), so we
+		 * suppress the new-tuple payload to keep records small.  Catalog
+		 * combo CIDs are not relevant for relocations of user tables; if a
+		 * caller ever uses heap_relocate on a system catalog, that path
+		 * would need additional work.
+		 */
+		recptr = log_heap_update(relation, buffer, newbuf,
+								 &oldtup, newtup,
+								 NULL,	/* old_key_tuple */
+								 all_visible_cleared,
+								 all_visible_cleared_new,
+								 false, /* walLogical */
+								 true /* is_relocation */ );
+		PageSetLSN(newpage, recptr);
+		PageSetLSN(page, recptr);
+	}
+
+	END_CRIT_SECTION();
+
+	LockBuffer(newbuf, BUFFER_LOCK_UNLOCK);
+	LockBuffer(buffer, BUFFER_LOCK_UNLOCK);
+
+	CacheInvalidateHeapTuple(relation, &oldtup, newtup);
+
+	ReleaseBuffer(newbuf);
+	ReleaseBuffer(buffer);
+	if (vmbuffer_new != InvalidBuffer)
+		ReleaseBuffer(vmbuffer_new);
+	if (vmbuffer != InvalidBuffer)
+		ReleaseBuffer(vmbuffer);
+
+	pgstat_count_heap_update(relation, false, true);
+
+	if (new_tid != NULL)
+		*new_tid = newtup->t_self;
+
+	heap_freetuple(newtup);
+
+	*update_indexes = TU_All;
+	return TM_Ok;
+}
+
 #ifdef USE_ASSERT_CHECKING
 /*
  * Confirm adequate lock held during heap_update(), per rules from
@@ -8776,7 +9207,7 @@ log_heap_update(Relation reln, Buffer oldbuf,
 				Buffer newbuf, HeapTuple oldtup, HeapTuple newtup,
 				HeapTuple old_key_tuple,
 				bool all_visible_cleared, bool new_all_visible_cleared,
-				bool walLogical)
+				bool walLogical, bool is_relocation)
 {
 	xl_heap_update xlrec;
 	xl_heap_header xlhdr;
@@ -8863,6 +9294,8 @@ log_heap_update(Relation reln, Buffer oldbuf,
 		xlrec.flags |= XLH_UPDATE_PREFIX_FROM_OLD;
 	if (suffixlen > 0)
 		xlrec.flags |= XLH_UPDATE_SUFFIX_FROM_OLD;
+	if (is_relocation)
+		xlrec.flags |= XLH_UPDATE_RELOCATED;
 	if (need_tuple_data)
 	{
 		xlrec.flags |= XLH_UPDATE_CONTAINS_NEW_TUPLE;
diff --git a/src/backend/replication/logical/decode.c b/src/backend/replication/logical/decode.c
index 38c5a4f5540..4e58df6513b 100644
--- a/src/backend/replication/logical/decode.c
+++ b/src/backend/replication/logical/decode.c
@@ -998,6 +998,15 @@ DecodeUpdate(LogicalDecodingContext *ctx, XLogRecordBuffer *buf)
 	if (FilterByOrigin(ctx, XLogRecGetOrigin(r)))
 		return;
 
+	/*
+	 * Relocations performed by VACUUM (COMPACT) carry byte-identical tuple
+	 * contents.  They are logged as ordinary updates so crash recovery sees
+	 * the page changes, but logical decoding must not surface them: the row
+	 * has not changed from the perspective of any subscriber.
+	 */
+	if (xlrec->flags & XLH_UPDATE_RELOCATED)
+		return;
+
 	change = ReorderBufferAllocChange(ctx->reorder);
 	change->action = REORDER_BUFFER_CHANGE_UPDATE;
 	change->origin_id = XLogRecGetOrigin(r);
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 5176478c295..1492169dd43 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -390,6 +390,13 @@ extern TM_Result heap_update(Relation relation, const ItemPointerData *otid,
 							 Snapshot crosscheck, bool wait,
 							 TM_FailureData *tmfd, LockTupleMode *lockmode,
 							 TU_UpdateIndexes *update_indexes);
+extern TM_Result heap_relocate(Relation relation,
+							   const ItemPointerData *otid,
+							   BlockNumber target_block,
+							   CommandId cid,
+							   TM_FailureData *tmfd,
+							   TU_UpdateIndexes *update_indexes,
+							   ItemPointer new_tid);
 extern TM_Result heap_lock_tuple(Relation relation, HeapTuple tuple,
 								 CommandId cid, LockTupleMode mode, LockWaitPolicy wait_policy,
 								 bool follow_updates,
diff --git a/src/include/access/heapam_xlog.h b/src/include/access/heapam_xlog.h
index fdca7d821c8..a0d0d45510e 100644
--- a/src/include/access/heapam_xlog.h
+++ b/src/include/access/heapam_xlog.h
@@ -90,6 +90,13 @@
 #define XLH_UPDATE_CONTAINS_NEW_TUPLE			(1<<4)
 #define XLH_UPDATE_PREFIX_FROM_OLD				(1<<5)
 #define XLH_UPDATE_SUFFIX_FROM_OLD				(1<<6)
+/*
+ * Set by heap_relocate (VACUUM (COMPACT)) to signal that the new tuple is
+ * byte-identical to the old.  Replay treats the record like any other
+ * cross-page update; logical decoding drops it so replication does not
+ * emit phantom no-op UPDATEs.
+ */
+#define XLH_UPDATE_RELOCATED					(1<<7)
 
 /* convenience macro for checking whether any form of old tuple was logged */
 #define XLH_UPDATE_CONTAINS_OLD						\
-- 
2.47.3

