From 4eef7b6380af90616031637feb0321616abb4fb1 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Sun, 19 Jul 2026 15:43:12 -0400
Subject: [PATCH v1 2/2] Add empty nbtree index SSI tests.

---
 src/backend/access/nbtree/nbtsearch.c         | 12 +++
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/btree-empty-ssi.out              | 99 +++++++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/btree-empty-ssi.spec                | 76 ++++++++++++++
 5 files changed, 189 insertions(+)
 create mode 100644 src/test/modules/injection_points/expected/btree-empty-ssi.out
 create mode 100644 src/test/modules/injection_points/specs/btree-empty-ssi.spec

diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index dfcdd2d4c..cab370eab 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -18,10 +18,12 @@
 #include "access/nbtree.h"
 #include "access/relscan.h"
 #include "access/xact.h"
+#include "catalog/catalog.h"
 #include "executor/instrument_node.h"
 #include "miscadmin.h"
 #include "pgstat.h"
 #include "storage/predicate.h"
+#include "utils/injection_point.h"
 #include "utils/lsyscache.h"
 #include "utils/rel.h"
 
@@ -1516,6 +1518,11 @@ _bt_first(IndexScanDesc scan, ScanDirection dir)
 	{
 		Assert(!so->needPrimScan);
 
+#ifdef USE_INJECTION_POINTS
+		if (!IsCatalogRelation(rel))
+			INJECTION_POINT("btree-first-empty", NULL);
+#endif
+
 		/*
 		 * We only get here if the index is completely empty. Lock relation
 		 * because nothing finer to lock exists.  Without a buffer lock, it's
@@ -2194,6 +2201,11 @@ _bt_endpoint(IndexScanDesc scan, ScanDirection dir)
 
 	if (!BufferIsValid(so->currPos.buf))
 	{
+#ifdef USE_INJECTION_POINTS
+		if (!IsCatalogRelation(rel))
+			INJECTION_POINT("btree-endpoint-empty", NULL);
+#endif
+
 		/*
 		 * Empty index. Lock the whole relation using the approach explained
 		 * at the same point in the _bt_first path.
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index c01d2fb09..1782d20dc 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -13,6 +13,7 @@ REGRESS = injection_points hashagg reindex_conc vacuum
 REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 
 ISOLATION = basic \
+	    btree-empty-ssi \
 	    inplace \
 	    repack \
 	    repack_temporal \
diff --git a/src/test/modules/injection_points/expected/btree-empty-ssi.out b/src/test/modules/injection_points/expected/btree-empty-ssi.out
new file mode 100644
index 000000000..309458ffb
--- /dev/null
+++ b/src/test/modules/injection_points/expected/btree-empty-ssi.out
@@ -0,0 +1,99 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s2_begin s1_scan_first s2_scan s2_insert s2_commit s2_wakeup_first s1_insert s1_commit s2_detach
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_begin: 
+	BEGIN ISOLATION LEVEL SERIALIZABLE;
+	SET LOCAL enable_seqscan = off;
+	SET LOCAL enable_bitmapscan = off;
+
+step s2_begin: BEGIN ISOLATION LEVEL SERIALIZABLE;
+step s1_scan_first: SELECT id FROM ssi_btree WHERE id = 2; <waiting ...>
+step s2_scan: SELECT id FROM ssi_btree;
+id
+--
+(0 rows)
+
+step s2_insert: INSERT INTO ssi_btree VALUES (2);
+step s2_commit: COMMIT;
+step s2_wakeup_first: SELECT injection_points_wakeup('btree-first-empty');
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s1_scan_first: <... completed>
+id
+--
+(0 rows)
+
+step s1_insert: INSERT INTO ssi_btree VALUES (1);
+ERROR:  could not serialize access due to read/write dependencies among transactions
+step s1_commit: COMMIT;
+step s2_detach: 
+	SELECT injection_points_detach('btree-first-empty');
+	SELECT injection_points_detach('btree-endpoint-empty');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+
+starting permutation: s1_begin s2_begin s1_scan_endpoint s2_scan s2_insert s2_commit s2_wakeup_endpoint s1_insert s1_commit s2_detach
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_begin: 
+	BEGIN ISOLATION LEVEL SERIALIZABLE;
+	SET LOCAL enable_seqscan = off;
+	SET LOCAL enable_bitmapscan = off;
+
+step s2_begin: BEGIN ISOLATION LEVEL SERIALIZABLE;
+step s1_scan_endpoint: SELECT id FROM ssi_btree ORDER BY id; <waiting ...>
+step s2_scan: SELECT id FROM ssi_btree;
+id
+--
+(0 rows)
+
+step s2_insert: INSERT INTO ssi_btree VALUES (2);
+step s2_commit: COMMIT;
+step s2_wakeup_endpoint: SELECT injection_points_wakeup('btree-endpoint-empty');
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s1_scan_endpoint: <... completed>
+id
+--
+(0 rows)
+
+step s1_insert: INSERT INTO ssi_btree VALUES (1);
+ERROR:  could not serialize access due to read/write dependencies among transactions
+step s1_commit: COMMIT;
+step s2_detach: 
+	SELECT injection_points_detach('btree-first-empty');
+	SELECT injection_points_detach('btree-endpoint-empty');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index 59dba1cb0..a466ce110 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -44,6 +44,7 @@ tests += {
   'isolation': {
     'specs': [
       'basic',
+      'btree-empty-ssi',
       'inplace',
       'repack',
       'repack_temporal',
diff --git a/src/test/modules/injection_points/specs/btree-empty-ssi.spec b/src/test/modules/injection_points/specs/btree-empty-ssi.spec
new file mode 100644
index 000000000..91b115f49
--- /dev/null
+++ b/src/test/modules/injection_points/specs/btree-empty-ssi.spec
@@ -0,0 +1,76 @@
+# Test SSI's handling of concurrent insertions into an initially empty
+# btree index.
+#
+# When predicate-locking a completely empty btree there is no page to
+# lock, so we lock the whole relation instead.  This was racy: without a
+# buffer lock held, a concurrent transaction can insert a matching key
+# between the descent that found the index empty and the
+# PredicateLockRelation() call.  The scan then misses the inserted tuple,
+# but the writer doesn't see the reader's predicate lock either, allowing
+# a write skew anomaly to go undetected.
+
+setup
+{
+	CREATE EXTENSION injection_points;
+	CREATE TABLE ssi_btree (id int PRIMARY KEY);
+}
+
+teardown
+{
+	DROP TABLE ssi_btree;
+	DROP EXTENSION injection_points;
+}
+
+session s1
+setup	{
+	SELECT injection_points_set_local();
+	SELECT injection_points_attach('btree-first-empty', 'wait');
+	SELECT injection_points_attach('btree-endpoint-empty', 'wait');
+}
+step s1_begin	{
+	BEGIN ISOLATION LEVEL SERIALIZABLE;
+	SET LOCAL enable_seqscan = off;
+	SET LOCAL enable_bitmapscan = off;
+}
+# Scan with a useful insertion scan key: descends via _bt_first/_bt_search.
+step s1_scan_first		{ SELECT id FROM ssi_btree WHERE id = 2; }
+# Scan without useful insertion scan keys: starts at _bt_endpoint().
+step s1_scan_endpoint	{ SELECT id FROM ssi_btree ORDER BY id; }
+step s1_insert	{ INSERT INTO ssi_btree VALUES (1); }
+step s1_commit	{ COMMIT; }
+
+session s2
+step s2_begin	{ BEGIN ISOLATION LEVEL SERIALIZABLE; }
+step s2_scan	{ SELECT id FROM ssi_btree; }
+step s2_insert	{ INSERT INTO ssi_btree VALUES (2); }
+step s2_commit	{ COMMIT; }
+step s2_wakeup_first	{ SELECT injection_points_wakeup('btree-first-empty'); }
+step s2_wakeup_endpoint	{ SELECT injection_points_wakeup('btree-endpoint-empty'); }
+step s2_detach	{
+	SELECT injection_points_detach('btree-first-empty');
+	SELECT injection_points_detach('btree-endpoint-empty');
+}
+
+# _bt_first()/_bt_search() path
+permutation s1_begin
+    s2_begin
+    s1_scan_first
+    s2_scan
+    s2_insert
+    s2_commit
+    s2_wakeup_first
+    s1_insert
+    s1_commit
+    s2_detach
+
+# _bt_endpoint() path
+permutation s1_begin
+    s2_begin
+    s1_scan_endpoint
+    s2_scan
+    s2_insert
+    s2_commit
+    s2_wakeup_endpoint
+    s1_insert
+    s1_commit
+    s2_detach
-- 
2.53.0

