From efc0b7c8a004ddab79b478d6bc2730014940835d Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Sun, 19 Jul 2026 18:37:26 -0400
Subject: [PATCH v1 2/2] Add test coverage for nbtree backwards scans

Backwards scans have unique concurrency rules: rather than trusting a
saved left link, the scan moves left by locating the page whose right
link points to the page that the scan just read, recovering when
concurrent page splits and page deletions have intervened (see
nbtree/README for details).  The recovery paths in
_bt_lock_and_validate_left had no test coverage.

Add injection points to each recovery path, plus an isolation test whose
permutations cover: recovering from a single concurrent page split by
stepping right; giving up after stepping right the maximum number of
times, and starting over using the just-read page's current left link;
ending the scan upon finding that every page to the left of the scan was
concurrently deleted; and recovering from concurrent deletion of the
just-read page itself.
---
 src/backend/access/nbtree/nbtsearch.c         |  23 ++
 src/test/modules/nbtree/Makefile              |   2 +
 .../modules/nbtree/expected/backwards.out     | 300 ++++++++++++++++++
 src/test/modules/nbtree/meson.build           |   6 +
 src/test/modules/nbtree/specs/backwards.spec  | 113 +++++++
 5 files changed, 444 insertions(+)
 create mode 100644 src/test/modules/nbtree/expected/backwards.out
 create mode 100644 src/test/modules/nbtree/specs/backwards.spec

diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index aae6acb7f..d9380d4af 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"
 
@@ -1977,6 +1979,11 @@ _bt_lock_and_validate_left(Relation rel, BlockNumber *blkno,
 {
 	BlockNumber origblkno = *blkno; /* detects circular links */
 
+#ifdef USE_INJECTION_POINTS
+	if (!IsCatalogRelation(rel))
+		INJECTION_POINT("lock-and-validate-left", NULL);
+#endif
+
 	for (;;)
 	{
 		Buffer		buf;
@@ -2011,6 +2018,12 @@ _bt_lock_and_validate_left(Relation rel, BlockNumber *blkno,
 			}
 			if (P_RIGHTMOST(opaque) || ++tries > 4)
 				break;
+
+#ifdef USE_INJECTION_POINTS
+			if (!IsCatalogRelation(rel))
+				INJECTION_POINT("lock-and-validate-step-right", NULL);
+#endif
+
 			/* step right */
 			*blkno = opaque->btpo_next;
 			buf = _bt_relandgetbuf(rel, buf, *blkno, BT_READ);
@@ -2028,6 +2041,11 @@ _bt_lock_and_validate_left(Relation rel, BlockNumber *blkno,
 		opaque = BTPageGetOpaque(page);
 		if (P_ISDELETED(opaque))
 		{
+#ifdef USE_INJECTION_POINTS
+			if (!IsCatalogRelation(rel))
+				INJECTION_POINT("lock-and-validate-lastcurr-deleted", NULL);
+#endif
+
 			/*
 			 * It was deleted.  Move right to first nondeleted page (there
 			 * must be one); that is the page that has acquired the deleted
@@ -2075,6 +2093,11 @@ _bt_lock_and_validate_left(Relation rel, BlockNumber *blkno,
 		/* Start from scratch with new lastcurrblkno's blkno/prev link */
 		*blkno = origblkno = opaque->btpo_prev;
 		_bt_relbuf(rel, buf);
+
+#ifdef USE_INJECTION_POINTS
+		if (!IsCatalogRelation(rel))
+			INJECTION_POINT("lock-and-validate-new-lastcurrblkno", NULL);
+#endif
 	}
 
 	return InvalidBuffer;
diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile
index eec264b16..3ad56ac65 100644
--- a/src/test/modules/nbtree/Makefile
+++ b/src/test/modules/nbtree/Makefile
@@ -5,6 +5,8 @@ EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck
 REGRESS = nbtree_half_dead_pages \
 	nbtree_incomplete_splits
 
+ISOLATION = backwards
+
 ifdef USE_PGXS
 PG_CONFIG = pg_config
 PGXS := $(shell $(PG_CONFIG) --pgxs)
diff --git a/src/test/modules/nbtree/expected/backwards.out b/src/test/modules/nbtree/expected/backwards.out
new file mode 100644
index 000000000..391ce9c9c
--- /dev/null
+++ b/src/test/modules/nbtree/expected/backwards.out
@@ -0,0 +1,300 @@
+Parsed test spec with 2 sessions
+
+starting permutation: b_attach b_scan i_insert i_detach b_detach
+step b_attach: 
+  SELECT injection_points_attach('lock-and-validate-left', 'wait');
+  SELECT injection_points_attach('lock-and-validate-step-right', 'notice');
+  SELECT injection_points_attach('lock-and-validate-new-lastcurrblkno', 'notice');
+  SELECT injection_points_attach('lock-and-validate-lastcurr-deleted', 'notice');
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step b_scan: SELECT col FROM backwards_scan_tbl
+              WHERE col % 100 = 1 ORDER BY col DESC; <waiting ...>
+step i_insert: INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(-2000, 700) i;
+step i_detach: 
+  SELECT injection_points_detach('lock-and-validate-left');
+  SELECT injection_points_wakeup('lock-and-validate-left');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+backwards_scan_session: NOTICE:  notice triggered for injection point lock-and-validate-step-right
+backwards_scan_session: NOTICE:  notice triggered for injection point lock-and-validate-step-right
+backwards_scan_session: NOTICE:  notice triggered for injection point lock-and-validate-step-right
+backwards_scan_session: NOTICE:  notice triggered for injection point lock-and-validate-step-right
+backwards_scan_session: NOTICE:  notice triggered for injection point lock-and-validate-new-lastcurrblkno
+step b_scan: <... completed>
+col
+---
+601
+501
+401
+301
+201
+101
+  1
+(7 rows)
+
+step b_detach: 
+  SELECT injection_points_detach('lock-and-validate-step-right');
+  SELECT injection_points_detach('lock-and-validate-new-lastcurrblkno');
+  SELECT injection_points_detach('lock-and-validate-lastcurr-deleted');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+
+starting permutation: b_attach b_scan i_insert_dups i_detach b_detach
+step b_attach: 
+  SELECT injection_points_attach('lock-and-validate-left', 'wait');
+  SELECT injection_points_attach('lock-and-validate-step-right', 'notice');
+  SELECT injection_points_attach('lock-and-validate-new-lastcurrblkno', 'notice');
+  SELECT injection_points_attach('lock-and-validate-lastcurr-deleted', 'notice');
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step b_scan: SELECT col FROM backwards_scan_tbl
+              WHERE col % 100 = 1 ORDER BY col DESC; <waiting ...>
+step i_insert_dups: INSERT INTO backwards_scan_tbl SELECT 100 FROM generate_series(1, 60);
+step i_detach: 
+  SELECT injection_points_detach('lock-and-validate-left');
+  SELECT injection_points_wakeup('lock-and-validate-left');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+backwards_scan_session: NOTICE:  notice triggered for injection point lock-and-validate-step-right
+step b_scan: <... completed>
+col
+---
+601
+501
+401
+301
+201
+101
+  1
+(7 rows)
+
+step b_detach: 
+  SELECT injection_points_detach('lock-and-validate-step-right');
+  SELECT injection_points_detach('lock-and-validate-new-lastcurrblkno');
+  SELECT injection_points_detach('lock-and-validate-lastcurr-deleted');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+
+starting permutation: b_attach d_delete_left b_scan vacuum_tbl i_detach b_detach
+step b_attach: 
+  SELECT injection_points_attach('lock-and-validate-left', 'wait');
+  SELECT injection_points_attach('lock-and-validate-step-right', 'notice');
+  SELECT injection_points_attach('lock-and-validate-new-lastcurrblkno', 'notice');
+  SELECT injection_points_attach('lock-and-validate-lastcurr-deleted', 'notice');
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step d_delete_left: DELETE FROM backwards_scan_tbl WHERE col < 601;
+step b_scan: SELECT col FROM backwards_scan_tbl
+              WHERE col % 100 = 1 ORDER BY col DESC; <waiting ...>
+step vacuum_tbl: VACUUM backwards_scan_tbl;
+step i_detach: 
+  SELECT injection_points_detach('lock-and-validate-left');
+  SELECT injection_points_wakeup('lock-and-validate-left');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+backwards_scan_session: NOTICE:  notice triggered for injection point lock-and-validate-step-right
+step b_scan: <... completed>
+col
+---
+601
+(1 row)
+
+step b_detach: 
+  SELECT injection_points_detach('lock-and-validate-step-right');
+  SELECT injection_points_detach('lock-and-validate-new-lastcurrblkno');
+  SELECT injection_points_detach('lock-and-validate-lastcurr-deleted');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+
+starting permutation: b_attach_nosr i_grow d_delete_mid b_scan_999 vacuum_tbl i_detach b_detach_nosr
+step b_attach_nosr: 
+  SELECT injection_points_attach('lock-and-validate-left', 'wait');
+  SELECT injection_points_attach('lock-and-validate-new-lastcurrblkno', 'notice');
+  SELECT injection_points_attach('lock-and-validate-lastcurr-deleted', 'notice');
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step i_grow: INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(701, 2200) i;
+step d_delete_mid: DELETE FROM backwards_scan_tbl WHERE col BETWEEN 367 AND 2100;
+step b_scan_999: SELECT col FROM backwards_scan_tbl
+                  WHERE col <= 999 AND col % 100 = 1 ORDER BY col DESC; <waiting ...>
+step vacuum_tbl: VACUUM backwards_scan_tbl;
+step i_detach: 
+  SELECT injection_points_detach('lock-and-validate-left');
+  SELECT injection_points_wakeup('lock-and-validate-left');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+backwards_scan_session: NOTICE:  notice triggered for injection point lock-and-validate-lastcurr-deleted
+backwards_scan_session: NOTICE:  notice triggered for injection point lock-and-validate-new-lastcurrblkno
+step b_scan_999: <... completed>
+col
+---
+301
+201
+101
+  1
+(4 rows)
+
+step b_detach_nosr: 
+  SELECT injection_points_detach('lock-and-validate-new-lastcurrblkno');
+  SELECT injection_points_detach('lock-and-validate-lastcurr-deleted');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
diff --git a/src/test/modules/nbtree/meson.build b/src/test/modules/nbtree/meson.build
index 209c3323b..0fc78a081 100644
--- a/src/test/modules/nbtree/meson.build
+++ b/src/test/modules/nbtree/meson.build
@@ -14,4 +14,10 @@ tests += {
       'nbtree_incomplete_splits',
     ],
   },
+  'isolation': {
+    'specs': [
+      'backwards',
+    ],
+    'runningcheck': false, # see syscache-update-pruned
+  },
 }
diff --git a/src/test/modules/nbtree/specs/backwards.spec b/src/test/modules/nbtree/specs/backwards.spec
new file mode 100644
index 000000000..89770450b
--- /dev/null
+++ b/src/test/modules/nbtree/specs/backwards.spec
@@ -0,0 +1,113 @@
+# Backwards scan isolation test
+#
+# Test the concurrency rules used by backwards scans, which step left to the
+# current page's left sibling using a search that starts from its saved right
+# sibling (the page the scan just read).  Concurrent page splits and page
+# deletions can leave the scan's saved left link pointing to a page that is no
+# longer the correct one for the scan to read next, which the scan must
+# recover from.
+#
+# The lock-and-validate-left injection point is used to make the scan wait
+# "between pages", while a concurrent session performs page splits and/or
+# page deletions.  The other injection points generate notifications that
+# confirm that we have the desired test coverage:
+#
+# lock-and-validate-step-right fires each time the scan's search steps right
+# to recover from a concurrent page split (never more than 4 times per
+# search attempt).
+#
+# lock-and-validate-new-lastcurrblkno fires when the search gives up on the
+# scan's saved left link/right sibling pages, and starts over using the
+# right sibling page's current left link.
+#
+# lock-and-validate-lastcurr-deleted fires when the search finds that the
+# page that the scan just read was itself concurrently deleted.
+#
+# Note: the permutations' expected notifications (and the leaf pages that
+# each concurrent session step splits or deletes) assume the default 8KB
+# BLCKSZ.
+
+setup
+{
+  CREATE EXTENSION injection_points;
+  CREATE TABLE backwards_scan_tbl(col int4) WITH (autovacuum_enabled = off);
+  CREATE INDEX ON backwards_scan_tbl(col) WITH (deduplicate_items = off);
+  INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(0, 700) i;
+}
+setup
+{
+  VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) backwards_scan_tbl;
+}
+teardown
+{
+  DROP EXTENSION injection_points;
+  DROP TABLE backwards_scan_tbl;
+}
+
+session backwards_scan_session
+setup {
+  SELECT injection_points_set_local();
+  SET enable_seqscan=off;
+  SET enable_sort=off;
+  SET debug_parallel_query=off;
+}
+step b_attach {
+  SELECT injection_points_attach('lock-and-validate-left', 'wait');
+  SELECT injection_points_attach('lock-and-validate-step-right', 'notice');
+  SELECT injection_points_attach('lock-and-validate-new-lastcurrblkno', 'notice');
+  SELECT injection_points_attach('lock-and-validate-lastcurr-deleted', 'notice');
+}
+# Variant that doesn't attach to lock-and-validate-step-right, for
+# permutations whose number of step right attempts varies with the amount of
+# free space that index tuples' varying alignment padding leaves on each page
+step b_attach_nosr {
+  SELECT injection_points_attach('lock-and-validate-left', 'wait');
+  SELECT injection_points_attach('lock-and-validate-new-lastcurrblkno', 'notice');
+  SELECT injection_points_attach('lock-and-validate-lastcurr-deleted', 'notice');
+}
+step b_scan { SELECT col FROM backwards_scan_tbl
+              WHERE col % 100 = 1 ORDER BY col DESC; }
+step b_scan_999 { SELECT col FROM backwards_scan_tbl
+                  WHERE col <= 999 AND col % 100 = 1 ORDER BY col DESC; }
+step b_detach {
+  SELECT injection_points_detach('lock-and-validate-step-right');
+  SELECT injection_points_detach('lock-and-validate-new-lastcurrblkno');
+  SELECT injection_points_detach('lock-and-validate-lastcurr-deleted');
+}
+step b_detach_nosr {
+  SELECT injection_points_detach('lock-and-validate-new-lastcurrblkno');
+  SELECT injection_points_detach('lock-and-validate-lastcurr-deleted');
+}
+
+session concurrent_session
+step i_insert { INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(-2000, 700) i; }
+step i_insert_dups { INSERT INTO backwards_scan_tbl SELECT 100 FROM generate_series(1, 60); }
+step i_grow { INSERT INTO backwards_scan_tbl SELECT i FROM generate_series(701, 2200) i; }
+step d_delete_left { DELETE FROM backwards_scan_tbl WHERE col < 601; }
+step d_delete_mid { DELETE FROM backwards_scan_tbl WHERE col BETWEEN 367 AND 2100; }
+step vacuum_tbl { VACUUM backwards_scan_tbl; }
+step i_detach {
+  SELECT injection_points_detach('lock-and-validate-left');
+  SELECT injection_points_wakeup('lock-and-validate-left');
+}
+
+# Many concurrent page splits.  When the backwards scan session wakes up, its
+# search steps right the maximum number of times before giving up and
+# starting over with the right sibling page's current left link.
+permutation b_attach b_scan i_insert i_detach b_detach
+
+# A single concurrent page split.  When the backwards scan session wakes up,
+# its search recovers by stepping right just once.
+permutation b_attach b_scan i_insert_dups i_detach b_detach
+
+# Concurrent deletion of all pages to the left of the page that the scan just
+# read.  When the backwards scan session wakes up, its search determines that
+# the scan has no page to the left to move to, ending the scan.
+permutation b_attach d_delete_left b_scan vacuum_tbl i_detach b_detach
+
+# Concurrent deletion of the page that the scan just read (which the scan can
+# only safely rely on when a search locates its left sibling using its saved
+# right link, which the deleted page's right sibling has acquired).  The scan
+# just read a page whose tuples all pointed to dead-to-all heap tuples, which
+# VACUUM deletes during the scan's wait, along with all nearby pages.
+permutation b_attach_nosr i_grow d_delete_mid b_scan_999 vacuum_tbl i_detach b_detach_nosr
-- 
2.53.0

