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.