From 142c13af4363553a596772d92604e268259aafbf Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 26 Oct 2026 13:34:15 -0400
Subject: [PATCH v1] Don't wait for the hash index metapage lock just to count
 a tuple

Every hash index insert took the metapage's exclusive content lock to
increment hashm_ntuples and test whether a bucket split is due.  With
concurrent inserters that one buffer lock serializes them all: on a
32-core machine, single-row INSERT throughput into a hash-indexed table
peaks at 16 clients and falls as more are added.

hashm_ntuples only decides when to split.  So when an insert finds the
metapage lock taken, don't wait.  Leave the tuple uncounted, as a pending
count in a backend-local hash table keyed by index OID, and let this
backend's next insert into that index that gets the lock add it to the
metapage.  Inserts that added an overflow page still wait for the lock,
since a full bucket is when a split may be due, and so do inserts that
find 32 tuples already pending, which bounds how far the count can lag.
An insert that defers its count does not register the metapage in its
WAL record.  When an insert counts more than its own tuple, the
increment goes in the metapage's block data, and redo applies it.

Without concurrent inserters the lock is always free and the count is
exact, as before.  Tuples still pending when a backend exits are never
counted; that delays a split slightly, and a VACUUM that removes tuples
recounts them.  Since the count can now be low, don't let the
subtraction in _hash_vacuum_one_page() and its redo take it below zero.

On a 32-vCPU machine, single-row INSERT throughput into a hash-indexed
table goes from 236k to 553k tps at 64 clients.

This changes the XLOG_HASH_INSERT record: the metapage block reference
is now optional and may carry data.  Older servers cannot replay the
new records, so this is for master only, and XLOG_PAGE_MAGIC needs a
bump.

Discussion: https://postgr.es/m/ats7u1ISmgZaWcge@floki
---
 src/backend/access/hash/README                |  40 ++++-
 src/backend/access/hash/hash_xlog.c           |  17 +-
 src/backend/access/hash/hashinsert.c          | 125 ++++++++++++--
 src/backend/access/rmgrdesc/hashdesc.c        |  12 ++
 src/include/access/hash_xlog.h                |   3 +-
 src/test/modules/hash/Makefile                |   6 +-
 .../hash/expected/hash_deferred_count.out     | 156 ++++++++++++++++++
 src/test/modules/hash/meson.build             |   6 +
 .../modules/hash/sql/hash_deferred_count.sql  |  70 ++++++++
 .../modules/hash/t/001_concurrent_inserts.pl  |  94 +++++++++++
 src/tools/pgindent/typedefs.list              |   1 +
 11 files changed, 504 insertions(+), 26 deletions(-)
 create mode 100644 src/test/modules/hash/expected/hash_deferred_count.out
 create mode 100644 src/test/modules/hash/sql/hash_deferred_count.sql
 create mode 100644 src/test/modules/hash/t/001_concurrent_inserts.pl

diff --git a/src/backend/access/hash/README b/src/backend/access/hash/README
index fc9031117c9..837a356e91b 100644
--- a/src/backend/access/hash/README
+++ b/src/backend/access/hash/README
@@ -304,13 +304,19 @@ The insertion algorithm is rather similar:
 	release lock but not pin, read/exclusive-lock
      next page; repeat as needed
 	>> see below if no space in any page of bucket
-	take buffer content lock in exclusive mode on metapage
+	if we added an overflow page, or too many tuples are pending, take buffer
+	 content lock in exclusive mode on metapage
+	else try to take it without waiting
 	insert tuple at appropriate place in page
 	mark current page dirty
-	increment tuple count, decide if split needed
-	mark meta page dirty
+	if we got the metapage lock
+		add this tuple and any pending ones to the tuple count, decide if
+		 split needed
+		mark meta page dirty
+	else
+		remember the tuple as pending
 	write WAL for insertion of tuple
-	release the buffer content lock on metapage
+	release the buffer content lock on metapage, if we hold it
 	release buffer content lock on current page
 	if current page is not a bucket page, release the pin on bucket page
 	if split is needed, enter Split algorithm below
@@ -326,10 +332,24 @@ that readers do not see a partially-updated page.
 To avoid deadlock between readers and inserters, whenever there is a need
 to lock multiple buckets, we always take in the order suggested in Lock
 Definitions above.  This algorithm allows them a very high degree of
-concurrency.  (The exclusive metapage lock taken to update the tuple count
-is stronger than necessary, since readers do not care about the tuple count,
-but the lock is held for such a short time that this is probably not an
-issue.)
+concurrency.
+
+The tuple count in the metapage (hashm_ntuples) only decides when to split
+a bucket.  Taking the metapage lock on every insert just to bump it
+serializes concurrent inserters on that one buffer, so an inserter that
+finds the lock taken does not wait for it.  It leaves its tuple uncounted,
+as a per-backend pending count for that index, and the next of its inserts
+that gets the lock adds the pending tuples to the count.  An insert that
+had to add an overflow page always waits for the lock, because a full
+bucket is the sign that a split may be due, and so does an insert that
+would leave more than a few dozen tuples pending.  Without concurrent
+inserters the lock is always free, and the count is exact.
+
+Tuples still pending when a backend exits are never counted.  That costs
+at most a slightly late split, since a backend leaves at most 32 tuples
+per index uncounted, and only tuples that found the lock taken.  A VACUUM
+that removes tuples recounts them (see below), and the code that subtracts
+from the count does not let it go negative.
 
 When an inserter cannot find space in any existing page of a bucket, it
 must obtain an overflow page and add that page to the bucket's chain.
@@ -423,7 +443,9 @@ buckets did *not* change at any time during the scan.  This provides a way of
 correcting the stored tuple count if it gets out of sync for some reason.  But
 if a split or insertion does occur concurrently, the scan count is
 untrustworthy; instead, subtract the number of tuples deleted from the stored
-tuple count and use that.
+tuple count and use that.  (An insertion that left its tuple pending goes
+unnoticed here, but the error that causes is bounded by the cap on pending
+tuples.)
 
 Interlocking Between Scans and VACUUM
 -------------------------------------
diff --git a/src/backend/access/hash/hash_xlog.c b/src/backend/access/hash/hash_xlog.c
index e9a2b9aa9a7..63ae2cc0108 100644
--- a/src/backend/access/hash/hash_xlog.c
+++ b/src/backend/access/hash/hash_xlog.c
@@ -123,8 +123,18 @@ hash_xlog_insert(XLogReaderState *record)
 	if (BufferIsValid(buffer))
 		UnlockReleaseBuffer(buffer);
 
+	/* The metapage is included only if the insert updated the tuple count */
+	if (!XLogRecHasBlockRef(record, 1))
+		return;
+
 	if (XLogReadBufferForRedo(record, 1, &buffer) == BLK_NEEDS_REDO)
 	{
+		uint32		ntup_delta = 1;
+
+		if (XLogRecHasBlockData(record, 1))
+			memcpy(&ntup_delta, XLogRecGetBlockData(record, 1, NULL),
+				   sizeof(uint32));
+
 		/*
 		 * Note: in normal operation, we'd update the metapage while still
 		 * holding lock on the page we inserted into.  But during replay it's
@@ -133,7 +143,7 @@ hash_xlog_insert(XLogReaderState *record)
 		 */
 		page = BufferGetPage(buffer);
 		metap = HashPageGetMeta(page);
-		metap->hashm_ntuples += 1;
+		metap->hashm_ntuples += ntup_delta;
 
 		PageSetLSN(page, lsn);
 		MarkBufferDirty(buffer);
@@ -1027,7 +1037,10 @@ hash_xlog_vacuum_one_page(XLogReaderState *record)
 		metapage = BufferGetPage(metabuf);
 		metap = HashPageGetMeta(metapage);
 
-		metap->hashm_ntuples -= xldata->ntuples;
+		if (metap->hashm_ntuples > xldata->ntuples)
+			metap->hashm_ntuples -= xldata->ntuples;
+		else
+			metap->hashm_ntuples = 0;
 
 		PageSetLSN(metapage, lsn);
 		MarkBufferDirty(metabuf);
diff --git a/src/backend/access/hash/hashinsert.c b/src/backend/access/hash/hashinsert.c
index 3395bbc13f8..d07de40b6aa 100644
--- a/src/backend/access/hash/hashinsert.c
+++ b/src/backend/access/hash/hashinsert.c
@@ -20,8 +20,28 @@
 #include "access/xloginsert.h"
 #include "miscadmin.h"
 #include "storage/predicate.h"
+#include "utils/injection_point.h"
+#include "utils/memutils.h"
 #include "utils/rel.h"
 
+/*
+ * Tuples this backend inserted without adding them to the metapage's
+ * hashm_ntuples, because another backend held the metapage lock.  See the
+ * description of the insertion algorithm in the README.  Entries are keyed by
+ * index OID and never removed; a stale count left by a dropped or truncated
+ * index is at most HASH_MAX_PENDING_TUPLES, which is harmless.
+ */
+typedef struct HashPendingTuples
+{
+	Oid			indexrelid;		/* hash key */
+	uint32		ntuples;
+} HashPendingTuples;
+
+static HTAB *hash_pending_tuples = NULL;
+
+/* Wait for the metapage lock once this many tuples are pending */
+#define HASH_MAX_PENDING_TUPLES	32
+
 static void _hash_vacuum_one_page(Relation rel, Relation hrel,
 								  Buffer metabuf, Buffer buf);
 
@@ -47,6 +67,10 @@ _hash_doinsert(Relation rel, IndexTuple itup, Relation heapRel, bool sorted)
 	HashPageOpaque pageopaque;
 	Size		itemsz;
 	bool		do_expand;
+	bool		added_ovfl = false;
+	bool		metalocked;
+	HashPendingTuples *pending = NULL;
+	uint32		ntup_delta = 0;
 	uint32		hashkey;
 	Bucket		bucket;
 	OffsetNumber itup_off;
@@ -181,6 +205,7 @@ restart_insert:
 			/* chain to a new overflow page */
 			buf = _hash_addovflpage(rel, metabuf, buf, (buf == bucket_buf));
 			page = BufferGetPage(buf);
+			added_ovfl = true;
 
 			/* should fit now, given test above */
 			Assert(PageGetFreeSpace(page) >= itemsz);
@@ -193,8 +218,60 @@ restart_insert:
 	/*
 	 * Write-lock the metapage so we can increment the tuple count. After
 	 * incrementing it, check to see if it's time for a split.
+	 *
+	 * Every insert wants this lock, so under concurrency it serializes the
+	 * inserters.  If someone else holds it, don't wait: leave the tuple
+	 * pending, to be counted by this backend's next insert that gets the
+	 * lock.  Do wait if we just added an overflow page, since the bucket may
+	 * be due for a split, or if too many tuples are pending already.
 	 */
-	LockBuffer(metabuf, BUFFER_LOCK_EXCLUSIVE);
+	if (hash_pending_tuples != NULL)
+	{
+		Oid			indexrelid = RelationGetRelid(rel);
+
+		pending = hash_search(hash_pending_tuples, &indexrelid, HASH_FIND,
+							  NULL);
+	}
+
+	if (added_ovfl ||
+		(pending != NULL && pending->ntuples >= HASH_MAX_PENDING_TUPLES))
+	{
+		LockBuffer(metabuf, BUFFER_LOCK_EXCLUSIVE);
+		metalocked = true;
+	}
+	else
+	{
+#ifdef USE_INJECTION_POINTS
+		/* let tests act as if the lock was taken */
+		if (IS_INJECTION_POINT_ATTACHED("hash-insert-defer-count"))
+			metalocked = false;
+		else
+#endif
+			metalocked = ConditionalLockBuffer(metabuf);
+	}
+
+	if (!metalocked && pending == NULL)
+	{
+		Oid			indexrelid = RelationGetRelid(rel);
+		bool		found;
+
+		if (hash_pending_tuples == NULL)
+		{
+			HASHCTL		ctl;
+
+			ctl.keysize = sizeof(Oid);
+			ctl.entrysize = sizeof(HashPendingTuples);
+			ctl.hcxt = TopMemoryContext;
+			hash_pending_tuples = hash_create("hash index pending tuples",
+											  16, &ctl,
+											  HASH_ELEM | HASH_BLOBS |
+											  HASH_CONTEXT);
+		}
+		pending = hash_search(hash_pending_tuples, &indexrelid, HASH_ENTER,
+							  &found);
+		if (!found)
+			pending->ntuples = 0;
+	}
 
 	/* Do the update.  No ereport(ERROR) until changes are logged */
 	START_CRIT_SECTION();
@@ -203,15 +280,28 @@ restart_insert:
 	itup_off = _hash_pgaddtup(rel, buf, itemsz, itup, sorted);
 	MarkBufferDirty(buf);
 
-	/* metapage operations */
-	metap = HashPageGetMeta(metapage);
-	metap->hashm_ntuples += 1;
+	do_expand = false;
+	if (metalocked)
+	{
+		/* metapage operations */
+		ntup_delta = 1;
+		if (pending != NULL)
+		{
+			ntup_delta += pending->ntuples;
+			pending->ntuples = 0;
+		}
 
-	/* Make sure this stays in sync with _hash_expandtable() */
-	do_expand = metap->hashm_ntuples >
-		(double) metap->hashm_ffactor * (metap->hashm_maxbucket + 1);
+		metap = HashPageGetMeta(metapage);
+		metap->hashm_ntuples += ntup_delta;
 
-	MarkBufferDirty(metabuf);
+		/* Make sure this stays in sync with _hash_expandtable() */
+		do_expand = metap->hashm_ntuples >
+			(double) metap->hashm_ffactor * (metap->hashm_maxbucket + 1);
+
+		MarkBufferDirty(metabuf);
+	}
+	else
+		pending->ntuples++;
 
 	/* XLOG stuff */
 	if (RelationNeedsWAL(rel))
@@ -223,7 +313,12 @@ restart_insert:
 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHashInsert);
 
-		XLogRegisterBuffer(1, metabuf, REGBUF_STANDARD);
+		if (metalocked)
+		{
+			XLogRegisterBuffer(1, metabuf, REGBUF_STANDARD);
+			if (ntup_delta > 1)
+				XLogRegisterBufData(1, &ntup_delta, sizeof(uint32));
+		}
 
 		XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
 		XLogRegisterBufData(0, itup, IndexTupleSize(itup));
@@ -234,12 +329,14 @@ restart_insert:
 		recptr = XLogGetFakeLSN(rel);
 
 	PageSetLSN(BufferGetPage(buf), recptr);
-	PageSetLSN(BufferGetPage(metabuf), recptr);
+	if (metalocked)
+		PageSetLSN(BufferGetPage(metabuf), recptr);
 
 	END_CRIT_SECTION();
 
 	/* drop lock on metapage, but keep pin */
-	LockBuffer(metabuf, BUFFER_LOCK_UNLOCK);
+	if (metalocked)
+		LockBuffer(metabuf, BUFFER_LOCK_UNLOCK);
 
 	/*
 	 * Release the modified page and ensure to release the pin on primary
@@ -417,8 +514,12 @@ _hash_vacuum_one_page(Relation rel, Relation hrel, Buffer metabuf, Buffer buf)
 		pageopaque = HashPageGetOpaque(page);
 		pageopaque->hasho_flag &= ~LH_PAGE_HAS_DEAD_TUPLES;
 
+		/* the count can be low by pending tuples; don't let it go negative */
 		metap = HashPageGetMeta(BufferGetPage(metabuf));
-		metap->hashm_ntuples -= ndeletable;
+		if (metap->hashm_ntuples > ndeletable)
+			metap->hashm_ntuples -= ndeletable;
+		else
+			metap->hashm_ntuples = 0;
 
 		MarkBufferDirty(buf);
 		MarkBufferDirty(metabuf);
diff --git a/src/backend/access/rmgrdesc/hashdesc.c b/src/backend/access/rmgrdesc/hashdesc.c
index 593013afe9c..6b410b80638 100644
--- a/src/backend/access/rmgrdesc/hashdesc.c
+++ b/src/backend/access/rmgrdesc/hashdesc.c
@@ -46,6 +46,18 @@ hash_desc(StringInfo buf, XLogReaderState *record)
 				xl_hash_insert *xlrec = (xl_hash_insert *) rec;
 
 				appendStringInfo(buf, "off %u", xlrec->offnum);
+				/* a full-page image of the metapage drops the increment */
+				if (XLogRecHasBlockRef(record, 1) &&
+					(XLogRecHasBlockData(record, 1) ||
+					 !XLogRecHasBlockImage(record, 1)))
+				{
+					uint32		ntup_delta = 1;
+
+					if (XLogRecHasBlockData(record, 1))
+						memcpy(&ntup_delta, XLogRecGetBlockData(record, 1, NULL),
+							   sizeof(uint32));
+					appendStringInfo(buf, ", ntuples +%u", ntup_delta);
+				}
 				break;
 			}
 		case XLOG_HASH_ADD_OVFL_PAGE:
diff --git a/src/include/access/hash_xlog.h b/src/include/access/hash_xlog.h
index 149aed69dc5..af65e5b5ce3 100644
--- a/src/include/access/hash_xlog.h
+++ b/src/include/access/hash_xlog.h
@@ -57,7 +57,8 @@
  * This data record is used for XLOG_HASH_INSERT
  *
  * Backup Blk 0: original page (data contains the inserted tuple)
- * Backup Blk 1: metapage (HashMetaPageData)
+ * Backup Blk 1: metapage, present only if the tuple count was updated (data
+ * is the uint32 count increment if it is not 1)
  */
 typedef struct xl_hash_insert
 {
diff --git a/src/test/modules/hash/Makefile b/src/test/modules/hash/Makefile
index 977c05ef4d2..8d8b798ebac 100644
--- a/src/test/modules/hash/Makefile
+++ b/src/test/modules/hash/Makefile
@@ -1,8 +1,10 @@
 # src/test/modules/hash/Makefile
 
-EXTRA_INSTALL = src/test/modules/injection_points
+EXTRA_INSTALL = src/test/modules/injection_points contrib/pageinspect
 
-REGRESS = hash_incomplete_splits
+REGRESS = hash_deferred_count hash_incomplete_splits
+
+TAP_TESTS = 1
 
 ifdef USE_PGXS
 PG_CONFIG = pg_config
diff --git a/src/test/modules/hash/expected/hash_deferred_count.out b/src/test/modules/hash/expected/hash_deferred_count.out
new file mode 100644
index 00000000000..3b7b42738a3
--- /dev/null
+++ b/src/test/modules/hash/expected/hash_deferred_count.out
@@ -0,0 +1,156 @@
+-- Test the deferred tuple count of hash index inserts.
+--
+-- An insert that finds the metapage locked leaves its tuple pending instead
+-- of waiting for the lock.  The hash-insert-defer-count injection point makes
+-- every such insert take that path.
+CREATE EXTENSION injection_points;
+CREATE EXTENSION pageinspect;
+SELECT injection_points_set_local();
+ injection_points_set_local 
+----------------------------
+ 
+(1 row)
+
+CREATE TABLE hash_count_test (v int4) WITH (autovacuum_enabled = false);
+CREATE INDEX hash_count_index ON hash_count_test USING hash (v);
+-- Without contention, the count is exact
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 10) g;
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+ ntuples 
+---------
+      10
+(1 row)
+
+-- Deferred inserts leave the count alone, up to the cap of 32 pending
+SELECT injection_points_attach('hash-insert-defer-count', 'notice');
+ injection_points_attach 
+-------------------------
+ 
+(1 row)
+
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 20) g;
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+ ntuples 
+---------
+      10
+(1 row)
+
+-- Each insert past the cap waits for the lock and counts what is pending
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 13) g;
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+ ntuples 
+---------
+      43
+(1 row)
+
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 40) g;
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+ ntuples 
+---------
+      76
+(1 row)
+
+-- A pending count lives on across transactions, and is counted by the next
+-- insert that gets the lock
+SELECT injection_points_detach('hash-insert-defer-count');
+ injection_points_detach 
+-------------------------
+ 
+(1 row)
+
+INSERT INTO hash_count_test VALUES (1);
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+ ntuples 
+---------
+      84
+(1 row)
+
+SELECT count(*) FROM hash_count_test;
+ count 
+-------
+    84
+(1 row)
+
+-- Each index has its own pending count
+CREATE INDEX hash_count_index2 ON hash_count_test USING hash (v);
+SELECT injection_points_attach('hash-insert-defer-count', 'notice');
+ injection_points_attach 
+-------------------------
+ 
+(1 row)
+
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 5) g;
+SELECT injection_points_detach('hash-insert-defer-count');
+ injection_points_detach 
+-------------------------
+ 
+(1 row)
+
+INSERT INTO hash_count_test VALUES (1);
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+ ntuples 
+---------
+      90
+(1 row)
+
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index2', 0));
+ ntuples 
+---------
+      90
+(1 row)
+
+-- Inserts that add an overflow page wait for the lock, so a deferred count
+-- cannot stop the index from splitting.  Build a 2-bucket index, then insert
+-- with every lock-free insert deferred.
+DROP INDEX hash_count_index, hash_count_index2;
+TRUNCATE hash_count_test;
+INSERT INTO hash_count_test SELECT 1 FROM generate_series(1, 10);
+ANALYZE hash_count_test;
+CREATE INDEX hash_count_index ON hash_count_test USING hash (v);
+SELECT maxbucket FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+ maxbucket 
+-----------
+         1
+(1 row)
+
+SELECT injection_points_attach('hash-insert-defer-count', 'notice');
+ injection_points_attach 
+-------------------------
+ 
+(1 row)
+
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 10000) g;
+SELECT injection_points_detach('hash-insert-defer-count');
+ injection_points_detach 
+-------------------------
+ 
+(1 row)
+
+SELECT maxbucket > 16 AS split,
+       ntuples >= 10010 - 32 AND ntuples <= 10010 AS count_ok
+  FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+ split | count_ok 
+-------+----------
+ t     | t
+(1 row)
+
+-- Index scans see the deferred tuples
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM hash_count_test WHERE v = 1;
+ count 
+-------
+    11
+(1 row)
+
+SELECT count(*) FROM hash_count_test WHERE v = 9999;
+ count 
+-------
+     1
+(1 row)
+
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+DROP TABLE hash_count_test;
+DROP EXTENSION pageinspect;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/hash/meson.build b/src/test/modules/hash/meson.build
index 284ebab70ff..9b009607396 100644
--- a/src/test/modules/hash/meson.build
+++ b/src/test/modules/hash/meson.build
@@ -10,7 +10,13 @@ tests += {
   'bd': meson.current_build_dir(),
   'regress': {
     'sql': [
+      'hash_deferred_count',
       'hash_incomplete_splits',
     ],
   },
+  'tap': {
+    'tests': [
+      't/001_concurrent_inserts.pl',
+    ],
+  },
 }
diff --git a/src/test/modules/hash/sql/hash_deferred_count.sql b/src/test/modules/hash/sql/hash_deferred_count.sql
new file mode 100644
index 00000000000..ca96317751a
--- /dev/null
+++ b/src/test/modules/hash/sql/hash_deferred_count.sql
@@ -0,0 +1,70 @@
+-- Test the deferred tuple count of hash index inserts.
+--
+-- An insert that finds the metapage locked leaves its tuple pending instead
+-- of waiting for the lock.  The hash-insert-defer-count injection point makes
+-- every such insert take that path.
+CREATE EXTENSION injection_points;
+CREATE EXTENSION pageinspect;
+SELECT injection_points_set_local();
+
+CREATE TABLE hash_count_test (v int4) WITH (autovacuum_enabled = false);
+CREATE INDEX hash_count_index ON hash_count_test USING hash (v);
+
+-- Without contention, the count is exact
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 10) g;
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+
+-- Deferred inserts leave the count alone, up to the cap of 32 pending
+SELECT injection_points_attach('hash-insert-defer-count', 'notice');
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 20) g;
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+
+-- Each insert past the cap waits for the lock and counts what is pending
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 13) g;
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 40) g;
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+
+-- A pending count lives on across transactions, and is counted by the next
+-- insert that gets the lock
+SELECT injection_points_detach('hash-insert-defer-count');
+INSERT INTO hash_count_test VALUES (1);
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+SELECT count(*) FROM hash_count_test;
+
+-- Each index has its own pending count
+CREATE INDEX hash_count_index2 ON hash_count_test USING hash (v);
+SELECT injection_points_attach('hash-insert-defer-count', 'notice');
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 5) g;
+SELECT injection_points_detach('hash-insert-defer-count');
+INSERT INTO hash_count_test VALUES (1);
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+SELECT ntuples FROM hash_metapage_info(get_raw_page('hash_count_index2', 0));
+
+-- Inserts that add an overflow page wait for the lock, so a deferred count
+-- cannot stop the index from splitting.  Build a 2-bucket index, then insert
+-- with every lock-free insert deferred.
+DROP INDEX hash_count_index, hash_count_index2;
+TRUNCATE hash_count_test;
+INSERT INTO hash_count_test SELECT 1 FROM generate_series(1, 10);
+ANALYZE hash_count_test;
+CREATE INDEX hash_count_index ON hash_count_test USING hash (v);
+SELECT maxbucket FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+SELECT injection_points_attach('hash-insert-defer-count', 'notice');
+INSERT INTO hash_count_test SELECT g FROM generate_series(1, 10000) g;
+SELECT injection_points_detach('hash-insert-defer-count');
+SELECT maxbucket > 16 AS split,
+       ntuples >= 10010 - 32 AND ntuples <= 10010 AS count_ok
+  FROM hash_metapage_info(get_raw_page('hash_count_index', 0));
+
+-- Index scans see the deferred tuples
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM hash_count_test WHERE v = 1;
+SELECT count(*) FROM hash_count_test WHERE v = 9999;
+RESET enable_seqscan;
+RESET enable_bitmapscan;
+
+DROP TABLE hash_count_test;
+DROP EXTENSION pageinspect;
+DROP EXTENSION injection_points;
diff --git a/src/test/modules/hash/t/001_concurrent_inserts.pl b/src/test/modules/hash/t/001_concurrent_inserts.pl
new file mode 100644
index 00000000000..eccb052b3af
--- /dev/null
+++ b/src/test/modules/hash/t/001_concurrent_inserts.pl
@@ -0,0 +1,94 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Concurrent hash index inserts defer their tuple count when they find the
+# metapage locked.  Check that such inserts replay correctly on a standby,
+# that the index still splits as it grows, and that the deferred count stays
+# close to the real one even when sessions are short.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Utils;
+use PostgreSQL::Test::Cluster;
+use Test::More;
+
+my $primary = PostgreSQL::Test::Cluster->new('primary');
+$primary->init(allows_streaming => 1);
+$primary->append_conf('postgresql.conf', 'autovacuum = off');
+$primary->start;
+$primary->safe_psql('postgres', 'CREATE EXTENSION pageinspect');
+
+$primary->backup('bkp');
+my $standby = PostgreSQL::Test::Cluster->new('standby');
+$standby->init_from_backup($primary, 'bkp', has_streaming => 1);
+$standby->start;
+
+$primary->safe_psql(
+	'postgres', q{
+CREATE TABLE hash_conc (v int4);
+CREATE INDEX hash_conc_idx ON hash_conc USING hash (v);
+});
+
+my $meta = q{SELECT ntuples, maxbucket
+	FROM hash_metapage_info(get_raw_page('hash_conc_idx', 0))};
+my (undef, $maxbucket0) = split /\|/, $primary->safe_psql('postgres', $meta);
+
+# Long sessions, and short ones that exit with tuples pending
+$primary->pgbench(
+	'--no-vacuum --client=8 --jobs=8 --transactions=2000',
+	0,
+	[qr{actually processed}],
+	[qr{^$}],
+	'concurrent inserts',
+	{
+		'hash_conc_long' => q{
+\set v random(1, 1000000000)
+INSERT INTO hash_conc VALUES (:v);
+INSERT INTO hash_conc VALUES (:v + 1);
+}
+	});
+$primary->pgbench(
+	'--no-vacuum --client=8 --jobs=8 --transactions=200 --connect',
+	0,
+	[qr{actually processed}],
+	[qr{^$}],
+	'concurrent short sessions',
+	{
+		'hash_conc_short' => q{
+\set v random(1, 1000000000)
+INSERT INTO hash_conc VALUES (:v);
+}
+	});
+
+my $nrows = $primary->safe_psql('postgres', 'SELECT count(*) FROM hash_conc');
+is($nrows, 8 * 2000 * 2 + 8 * 200, 'all rows inserted');
+
+my ($ntuples, $maxbucket) =
+  split /\|/, $primary->safe_psql('postgres', $meta);
+cmp_ok($maxbucket, '>', $maxbucket0, 'index split');
+cmp_ok($ntuples, '<=', $nrows, 'tuple count not too high');
+# A session leaves fewer than 32 tuples uncounted, and a single-insert session
+# at most one
+cmp_ok($ntuples, '>=', $nrows - (8 * 32 + 8 * 200),
+	'tuple count not too low');
+
+$primary->wait_for_replay_catchup($standby);
+
+is($standby->safe_psql('postgres', $meta),
+	"$ntuples|$maxbucket", 'standby metapage matches primary');
+
+my $probe = q{
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT count(*) FROM hash_conc h
+  WHERE (SELECT count(*) FROM hash_conc i WHERE i.v = h.v) =
+        (SELECT count(*) FROM hash_conc s WHERE s.v + 0 = h.v)
+  AND h.v % 97 = 0};
+my $expected = $primary->safe_psql('postgres',
+	'SELECT count(*) FROM hash_conc WHERE v % 97 = 0');
+is($primary->safe_psql('postgres', $probe),
+	$expected, 'index probes match heap on primary');
+is($standby->safe_psql('postgres', $probe),
+	$expected, 'index probes match heap on standby');
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index a7a8dbdd23f..647e5c76b54 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1229,6 +1229,7 @@ HashPageOpaque
 HashPageOpaqueData
 HashPageStat
 HashPath
+HashPendingTuples
 HashScanOpaque
 HashScanOpaqueData
 HashScanPosData
-- 
2.54.0

