From 80b8ed85259bd160ab8ce1a589e6fae8caef1af7 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Tue, 25 Aug 2026 11:21:17 +0500
Subject: [PATCH v1] Extend B-tree indexes in batches

B-tree page splits can have to extend the index while holding an
exclusive lock on the page being split.  Extending one page at a time
makes this potentially expensive path recur for every split once the FSM
is empty.

Use ExtendBufferedRelBy() to grow geometrically up to a batch of 16
pages.  Return the first page to the split and make the remaining zero
pages available through the index FSM.

Relation extension is not WAL-logged.  FSM updates normally aren't
either, but an FSM page can reach a standby in a hint full-page image.
The standby can therefore have a shorter main fork and FSM entries beyond
its end.  The existing FSM main-fork length check makes those entries
safe.  Add a recovery test that exercises the different relation lengths
and subsequent use after promotion.

Discussion: https://postgr.es/m/d6do2mtjcsagn37jf6pjywzhlzyokqja6jlnvcs4ypkvnnuu32@llwuuyxxomc3
---
 src/backend/access/nbtree/nbtpage.c           | 32 ++++++++-
 src/backend/storage/freespace/indexfsm.c      | 15 +++++
 src/include/storage/indexfsm.h                |  2 +
 src/test/recovery/meson.build                 |  1 +
 .../recovery/t/056_btree_bulk_extension.pl    | 67 +++++++++++++++++++
 5 files changed, 115 insertions(+), 2 deletions(-)
 create mode 100644 src/test/recovery/t/056_btree_bulk_extension.pl

diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index ff7d2a93948..bf30b14bb9c 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -38,6 +38,8 @@
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
 
+#define BTREE_MAX_EXTEND_BY 16
+
 static BTMetaPageData *_bt_getmeta(Relation rel, Buffer metabuf);
 static void _bt_delitems_delete(Relation rel, Buffer buf,
 								TransactionId snapshotConflictHorizon,
@@ -853,9 +855,11 @@ _bt_getbuf(Relation rel, BlockNumber blkno, int access)
 Buffer
 _bt_allocbuf(Relation rel, Relation heaprel)
 {
+	Buffer		buffers[BTREE_MAX_EXTEND_BY];
 	Buffer		buf;
 	BlockNumber blkno;
 	Page		page;
+	uint32		extend_by;
 
 	Assert(heaprel != NULL);
 
@@ -954,13 +958,37 @@ _bt_allocbuf(Relation rel, Relation heaprel)
 	}
 
 	/*
-	 * Extend the relation by one page. Need to use RBM_ZERO_AND_LOCK or we
+	 * Extend the relation by several pages, retaining the first page for this
+	 * allocation and making the rest available through the FSM.  This
+	 * amortizes relation extension and buffer replacement across several
+	 * page splits.  Grow geometrically at first, so that this doesn't bloat
+	 * small indexes, and cap the batch to limit the number of buffers pinned
+	 * at once.
+	 *
+	 * Relation extension is not WAL-logged.  FSM updates normally aren't
+	 * either, but an FSM page can reach a standby in a hint full-page image.
+	 * The standby can therefore have FSM entries for reserved pages beyond the
+	 * end of its main fork.  GetFreeIndexPage() verifies the main fork's
+	 * length before returning such an entry.
+	 *
+	 * Need to use RBM_ZERO_AND_LOCK for the first page or we
 	 * risk a race condition against btvacuumscan --- see comments therein.
 	 * This forces us to repeat the valgrind request that _bt_lockbuf()
 	 * otherwise would make, as we can't use _bt_lockbuf() without introducing
 	 * a race.
 	 */
-	buf = ExtendBufferedRel(BMR_REL(rel), MAIN_FORKNUM, NULL, EB_LOCK_FIRST);
+	extend_by = Min(RelationGetNumberOfBlocks(rel), BTREE_MAX_EXTEND_BY);
+	extend_by = Max(extend_by, 1);
+	blkno = ExtendBufferedRelBy(BMR_REL(rel), MAIN_FORKNUM, NULL,
+								EB_LOCK_FIRST, extend_by, buffers, &extend_by);
+	buf = buffers[0];
+
+	for (uint32 i = 1; i < extend_by; i++)
+		ReleaseBuffer(buffers[i]);
+
+	if (extend_by > 1)
+		RecordFreeIndexPages(rel, blkno + 1, extend_by - 1);
+
 	if (!RelationUsesLocalBuffers(rel))
 		VALGRIND_MAKE_MEM_DEFINED(BufferGetPage(buf), BLCKSZ);
 
diff --git a/src/backend/storage/freespace/indexfsm.c b/src/backend/storage/freespace/indexfsm.c
index 85fbbab6c9c..7bc390fd235 100644
--- a/src/backend/storage/freespace/indexfsm.c
+++ b/src/backend/storage/freespace/indexfsm.c
@@ -54,6 +54,21 @@ RecordFreeIndexPage(Relation rel, BlockNumber freeBlock)
 	RecordPageWithFreeSpace(rel, freeBlock, BLCKSZ - 1);
 }
 
+/*
+ * RecordFreeIndexPages - mark a range of pages as free in the FSM
+ */
+void
+RecordFreeIndexPages(Relation rel, BlockNumber firstBlock, uint32 nblocks)
+{
+	BlockNumber lastBlock = firstBlock + nblocks;
+
+	Assert(nblocks > 0);
+
+	for (BlockNumber blkno = firstBlock; blkno < lastBlock; blkno++)
+		RecordFreeIndexPage(rel, blkno);
+
+	FreeSpaceMapVacuumRange(rel, firstBlock, lastBlock);
+}
 
 /*
  * RecordUsedIndexPage - mark a page as used in the FSM
diff --git a/src/include/storage/indexfsm.h b/src/include/storage/indexfsm.h
index 7174bcdff99..830b7585e79 100644
--- a/src/include/storage/indexfsm.h
+++ b/src/include/storage/indexfsm.h
@@ -19,6 +19,8 @@
 
 extern BlockNumber GetFreeIndexPage(Relation rel);
 extern void RecordFreeIndexPage(Relation rel, BlockNumber freeBlock);
+extern void RecordFreeIndexPages(Relation rel, BlockNumber firstBlock,
+								 uint32 nblocks);
 extern void RecordUsedIndexPage(Relation rel, BlockNumber usedBlock);
 
 extern void IndexFreeSpaceMapVacuum(Relation rel);
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index 39ec8c4946d..2c6cf344681 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -64,6 +64,7 @@ tests += {
       't/053_standby_login_event_trigger.pl',
       't/054_unlogged_sequence_promotion.pl',
       't/055_cascade_reconnect.pl',
+      't/056_btree_bulk_extension.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/056_btree_bulk_extension.pl b/src/test/recovery/t/056_btree_bulk_extension.pl
new file mode 100644
index 00000000000..3484b318b08
--- /dev/null
+++ b/src/test/recovery/t/056_btree_bulk_extension.pl
@@ -0,0 +1,67 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test recovery when B-tree bulk extension makes the primary's main fork
+# longer than the standby's.  Relation extension is not WAL-logged, while an
+# index FSM page can reach the standby in a hint full-page image.  The standby
+# may therefore have FSM entries for reserved pages that do not exist in its
+# main fork.
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $primary = PostgreSQL::Test::Cluster->new('primary');
+$primary->init(allows_streaming => 1, data_checksums => 1);
+$primary->append_conf(
+	'postgresql.conf', qq{
+autovacuum = off
+shared_buffers = '16MB'
+});
+$primary->start;
+
+$primary->backup('backup');
+my $standby = PostgreSQL::Test::Cluster->new('standby');
+$standby->init_from_backup($primary, 'backup', has_streaming => 1);
+$standby->start;
+
+$primary->safe_psql(
+	'postgres', q{
+CREATE TABLE btree_bulk_extension_test (i integer);
+CREATE INDEX btree_bulk_extension_idx ON btree_bulk_extension_test (i);
+INSERT INTO btree_bulk_extension_test SELECT i FROM generate_series(1, 10000) i;
+});
+
+$primary->wait_for_replay_catchup($standby);
+
+my $primary_pages = $primary->safe_psql(
+	'postgres',
+	q{SELECT pg_relation_size('btree_bulk_extension_idx') /
+             current_setting('block_size')::integer});
+my $standby_pages = $standby->safe_psql(
+	'postgres',
+	q{SELECT pg_relation_size('btree_bulk_extension_idx') /
+             current_setting('block_size')::integer});
+
+cmp_ok($primary_pages, '>', $standby_pages,
+	'reserved B-tree pages are not replayed on standby');
+
+$standby->promote;
+$standby->restart;
+
+$standby->safe_psql(
+	'postgres', q{
+INSERT INTO btree_bulk_extension_test SELECT i FROM generate_series(10001, 20000) i;
+});
+
+is(
+	$standby->safe_psql(
+		'postgres', q{
+SET enable_seqscan = off;
+SELECT count(*) FROM btree_bulk_extension_test WHERE i BETWEEN 1 AND 20000;
+}),
+	'20000',
+	'promoted standby can extend and scan the B-tree');
+
+done_testing();
-- 
That's all, folks. May the source be with you.

