From cee0bce6d97db2d8246ad0c8ea9ef268f896d1e2 Mon Sep 17 00:00:00 2001
From: Hannu Krosing <hannuk@google.com>
Date: Sun, 20 Sep 2026 21:54:20 +0000
Subject: [PATCH v4 9/9] Prune dead unindexed TOAST tuples directly to
 LP_UNUSED

In a TOAST relation, Direct TOAST chunks are addressed directly by physical
TID and have chunk_id IS NULL, meaning they are excluded from the TOAST
table's index (which is partial on WHERE chunk_id IS NOT NULL).

Consequently, when opportunistic page pruning or VACUUM encounters dead
Direct TOAST chunks, they do not require index vacuuming to clean up index
pointers. Special-case these unindexed TOAST tuples in pruneheap.c:
1. heap_tuple_header_is_unindexed_toast(): Check whether a tuple header has
   NULL attributes with attribute 0 (chunk_id) being NULL.
2. heap_prune_is_unindexed_toast_tuple(): Verify that the relation is a TOAST
   relation (relkind == RELKIND_TOASTVALUE) and the tuple header is unindexed.
3. heap_prune_record_dead_or_unused(): When pruning a dead normal tuple, if it
   is an unindexed TOAST tuple, mark it LP_UNUSED immediately via
   heap_prune_record_unused() rather than LP_DEAD, freeing the line pointer
   and reclaiming page space without waiting for a two-pass index vacuum.
4. heap_page_prune_execute(): Relax the assertion for items marked LP_UNUSED
   when ndead > 0 so that unindexed TOAST tuples are allowed alongside
   heap-only tuples.
---
 src/backend/access/heap/pruneheap.c | 59 +++++++++++++++++++++++++----
 1 file changed, 51 insertions(+), 8 deletions(-)

diff --git a/src/backend/access/heap/pruneheap.c b/src/backend/access/heap/pruneheap.c
index 50f810c8830..10d1e018672 100644
--- a/src/backend/access/heap/pruneheap.c
+++ b/src/backend/access/heap/pruneheap.c
@@ -1794,23 +1794,63 @@ heap_prune_record_dead(PruneState *prstate, OffsetNumber offnum,
 		prstate->ndeleted++;
 }
 
+/*
+ * Check if a tuple header belongs to an unindexed Direct TOAST tuple.
+ * In a TOAST table, chunk_id is the first attribute (attnum 1). Direct TOAST
+ * chunks leave chunk_id NULL since they are accessed directly by TID rather
+ * than via the TOAST index.
+ */
+static inline bool
+heap_tuple_header_is_unindexed_toast(HeapTupleHeader htup)
+{
+	return (htup != NULL &&
+			(htup->t_infomask & HEAP_HASNULL) != 0 &&
+			att_isnull(0, htup->t_bits));
+}
+
+static inline bool
+heap_prune_is_unindexed_toast_tuple(PruneState *prstate, HeapTupleHeader htup)
+{
+	if (prstate->relation->rd_rel->relkind == RELKIND_TOASTVALUE &&
+		heap_tuple_header_is_unindexed_toast(htup))
+		return true;
+
+	return false;
+}
+
 /*
  * Depending on whether or not the caller set mark_unused_now to true, record that a
  * line pointer should be marked LP_DEAD or LP_UNUSED. There are other cases in
  * which we will mark line pointers LP_UNUSED, but we will not mark line
  * pointers LP_DEAD if mark_unused_now is true.
+ *
+ * For TOAST relations, tuples without an index entry (chunk_id is NULL) can also
+ * be marked LP_UNUSED immediately without waiting for index vacuuming.
  */
 static void
 heap_prune_record_dead_or_unused(PruneState *prstate, OffsetNumber offnum,
 								 bool was_normal)
 {
+	bool		is_unindexed_toast = false;
+
+	if (!prstate->mark_unused_now && was_normal)
+	{
+		ItemId		lp = PageGetItemId(prstate->page, offnum);
+		HeapTupleHeader htup;
+
+		Assert(ItemIdHasStorage(lp) && ItemIdIsNormal(lp));
+		htup = (HeapTupleHeader) PageGetItem(prstate->page, lp);
+
+		is_unindexed_toast = heap_prune_is_unindexed_toast_tuple(prstate, htup);
+	}
+
 	/*
-	 * If the caller set mark_unused_now to true, we can remove dead tuples
-	 * during pruning instead of marking their line pointers dead. Set this
-	 * tuple's line pointer LP_UNUSED. We hint that this option is less
-	 * likely.
+	 * If the caller set mark_unused_now to true, or if this is an unindexed
+	 * TOAST tuple, we can remove dead tuples during pruning instead of
+	 * marking their line pointers dead. Set this tuple's line pointer
+	 * LP_UNUSED. We hint that this option is less likely.
 	 */
-	if (unlikely(prstate->mark_unused_now))
+	if (unlikely(prstate->mark_unused_now) || is_unindexed_toast)
 		heap_prune_record_unused(prstate, offnum, was_normal);
 	else
 		heap_prune_record_dead(prstate, offnum, was_normal);
@@ -1819,7 +1859,8 @@ heap_prune_record_dead_or_unused(PruneState *prstate, OffsetNumber offnum,
 	 * It's incorrect for the page to be set all-visible if it contains dead
 	 * items. Fix that on the heap page and check the VM for corruption as
 	 * well. Do that here rather than in heap_prune_record_dead() so we also
-	 * cover tuples that are directly marked LP_UNUSED via mark_unused_now.
+	 * cover tuples that are directly marked LP_UNUSED via mark_unused_now or
+	 * unindexed TOAST tuples.
 	 */
 	if (PageIsAllVisible(prstate->page))
 		heap_page_fix_vm_corruption(prstate, offnum, VM_CORRUPT_LPDEAD);
@@ -2217,13 +2258,15 @@ heap_page_prune_execute(Buffer buffer, bool lp_truncate_only,
 			 * items to be made LP_UNUSED instead.  This is only possible if
 			 * the relation has no indexes.  If there are any dead items, then
 			 * mark_unused_now was not true and every item being marked
-			 * LP_UNUSED must refer to a heap-only tuple.
+			 * LP_UNUSED must refer to a heap-only tuple or an unindexed
+			 * TOAST tuple.
 			 */
 			if (ndead > 0)
 			{
 				Assert(ItemIdHasStorage(lp) && ItemIdIsNormal(lp));
 				htup = (HeapTupleHeader) PageGetItem(page, lp);
-				Assert(HeapTupleHeaderIsHeapOnly(htup));
+				Assert(HeapTupleHeaderIsHeapOnly(htup) ||
+					   heap_tuple_header_is_unindexed_toast(htup));
 			}
 			else
 				Assert(ItemIdIsUsed(lp));
-- 
2.55.0.1082.g2b9226bbc0-goog

