From 80b8ed85259bd160ab8ce1a589e6fae8caef1af7 Mon Sep 17 00:00:00 2001 From: Andrey Borodin 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.