From e60d12ffeda3ce747d0fb9e43f11cb29523efb38 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 14 Sep 2026 12:58:42 -0400
Subject: [PATCH v4 2/3] Assert the reorder queue keeps a tuple's TID in the
 slot

IndexNextWithReorder() re-stores a queued tuple with
ExecForceStoreHeapTuple(), and slot_getsysattr() answers
SelfItemPointerAttributeNumber out of tts_tid alone, so a slot that
loses the TID silently projects a different ctid than the row it
returned.  Assert that the slot advertises the TID the tuple was
fetched from.

The invariant does not hold for slots in general, since HOT can
legitimately make tts_tid and the stored tuple's t_self differ, so the
check is confined to this path, where a divergence changes query
results.

The TID is captured before the store, which frees the tuple, and the
comparison uses the NoCheck accessors so that the sentinel trips this
assertion rather than the validity check inside ItemPointerEquals().

Suggested-by: Andres Freund
---
 src/backend/executor/nodeIndexscan.c | 21 +++++++++++++++++++++
 1 file changed, 21 insertions(+)

diff --git a/src/backend/executor/nodeIndexscan.c b/src/backend/executor/nodeIndexscan.c
index 6566150cddc..410aefe3e0f 100644
--- a/src/backend/executor/nodeIndexscan.c
+++ b/src/backend/executor/nodeIndexscan.c
@@ -248,11 +248,32 @@ IndexNextWithReorder(IndexScanState *node)
 								node) <= 0)
 			{
 				HeapTuple	tuple;
+				ItemPointerData tid PG_USED_FOR_ASSERTS_ONLY;
 
 				tuple = reorderqueue_pop(node);
 
+				/* Remember the TID; the store below frees the tuple. */
+				tid = tuple->t_self;
+
 				/* Pass 'true', as the tuple in the queue is a palloc'd copy */
 				ExecForceStoreHeapTuple(tuple, slot, true);
+
+				/*
+				 * The tuple came from the heap through this scan, so the slot
+				 * must advertise the TID it was fetched from.  If the two
+				 * diverge the scan projects a different ctid than the row it
+				 * returned, which changes query results.  This does not hold
+				 * for slots in general, since HOT can legitimately make them
+				 * differ, so assert it only here.
+				 *
+				 * Compare with the NoCheck accessors so that a slot left
+				 * holding the invalid-TID sentinel trips this assertion rather
+				 * than the validity one inside ItemPointerEquals().
+				 */
+				Assert(ItemPointerGetBlockNumberNoCheck(&slot->tts_tid) ==
+					   ItemPointerGetBlockNumberNoCheck(&tid) &&
+					   ItemPointerGetOffsetNumberNoCheck(&slot->tts_tid) ==
+					   ItemPointerGetOffsetNumberNoCheck(&tid));
 				return slot;
 			}
 		}
-- 
2.50.1

