Hi Salma,

While testing v3, I noticed a crash in _bt_killitems() triggered by
concurrent bt_merge().

The problem
-----------

An index scan reads leaf page L, drops its content lock (keeping the
pin), and visits the heap.  Finding dead tuples, it records them in
so->killedItems[].  Concurrently, bt_merge() acquires BT_WRITE on L,
moves all tuples to R, and calls BTPageSetMergedAway(), which sets
pd_lower to 32 and marks L as BTP_MERGED_AWAY.

When the scanner calls _bt_killitems() in !so->dropPin mode, it
re-acquires BT_READ and proceeds without checking page flags.
PageGetMaxOffsetNumber() returns (32 - 24) / 4 = 2, because the
8-byte safemergexid at bytes 24..31 overlaps with pd_linp[0..1].
The function then interprets safemergexid bits as ItemIdData, causing
Assert(ItemIdHasStorage) failures in debug builds or SIGSEGV in
production.

The so->dropPin path is not affected -- it checks LSN first and gives
up if the page was modified.  The !so->dropPin path assumes the pin
keeps page contents stable, which holds for standard VACUUM (cleanup
lock required) but not for bt_merge() (regular BT_WRITE by design).

Proposed fix
------------

Add a check for P_ISMERGEDAWAY after acquiring the lock:

+   /*
+    * bt_merge() can convert a page to BTP_MERGED_AWAY while we hold a
+    * pin but no lock.  The original tuples are gone; give up on hinting.
+    */
+   if (P_ISMERGEDAWAY(opaque))
+       goto unlock_page;

Abandoning LP_DEAD hints is harmless -- a subsequent VACUUM will clean
up the dead tuples.

Any thoughts on this?

Best regards,
Yao Feng
diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c
index 23950f949e..769e1be64a 100644
--- a/src/backend/access/nbtree/nbtutils.c
+++ b/src/backend/access/nbtree/nbtutils.c
@@ -28,6 +28,7 @@
 #include "storage/subsystems.h"
 #include "utils/datum.h"
 #include "utils/lsyscache.h"
+#include "utils/injection_point.h"
 #include "utils/rel.h"
 
 
@@ -204,6 +205,8 @@ _bt_killitems(IndexScanDesc scan)
 	Assert(BTScanPosIsValid(so->currPos));
 	Assert(scan->heapRelation != NULL); /* can't be a bitmap index scan */
 
+	INJECTION_POINT("before_bt_killitems", NULL);
+
 	/* Always invalidate so->killedItems[] before leaving so->currPos */
 	so->numKilled = 0;
 

Attachment: test_killitems_crash.sh
Description: Bourne shell script

From 4caf156985236ef0fa8d4fe4574d6bc559f250b9 Mon Sep 17 00:00:00 2001
From: Yao Feng <[email protected]>
Date: Tue, 22 Sep 2026 08:42:04 +0000
Subject: [PATCH] nbtree: Guard _bt_killitems against concurrent page merges

Avoid crashes in _bt_killitems() by returning early if the page was
concurrently merged away (P_ISMERGEDAWAY).
---
 src/backend/access/nbtree/nbtutils.c | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c
index 23950f949e..b9c110bea5 100644
--- a/src/backend/access/nbtree/nbtutils.c
+++ b/src/backend/access/nbtree/nbtutils.c
@@ -254,6 +254,14 @@ _bt_killitems(IndexScanDesc scan)
 
 	page = BufferGetPage(buf);
 	opaque = BTPageGetOpaque(page);
+
+	/*
+	 * bt_merge() can convert a page to BTP_MERGED_AWAY while we hold a
+	 * pin but no lock.  The original tuples are gone; give up on hinting.
+	 */
+	if (P_ISMERGEDAWAY(opaque))
+		goto unlock_page;
+
 	minoff = P_FIRSTDATAKEY(opaque);
 	maxoff = PageGetMaxOffsetNumber(page);
 
-- 
2.53.0

Reply via email to