]> git.ipfire.org Git - thirdparty/postgresql.git/commitdiff
Add test coverage for nbtree backwards scans. master github/master
authorPeter Geoghegan <pg@bowt.ie>
Sat, 25 Jul 2026 21:13:00 +0000 (17:13 -0400)
committerPeter Geoghegan <pg@bowt.ie>
Sat, 25 Jul 2026 21:13:00 +0000 (17:13 -0400)
Backwards scans have unique concurrency rules: rather than unreservedly
trusting a saved left link, the scan optimistically rechecks its
pointed-to leaf page's right link (i.e. whether it still points back to
the page that _bt_readpage just read).  Usually, the left sibling of the
just-read page won't have changed, in which case the scan can proceed
with reading the left sibling as planned.  But it's possible that the
key space that the scan needs to read next is no longer covered by the
original left sibling page due to concurrent page splits and/or page
deletions.  When that happens, the scan must recover by relocating the
new/current left sibling of the just-read page.

Test coverage for backwards scans was limited to the happy path.  Add an
isolation test (and associated injection points) that test the recovery
path.  This covers several distinct recovery scenarios (concurrent page
splits, concurrent page deletions, and minor variants thereof).

Author: Peter Geoghegan <pg@bowt.ie>
Reviewed-by: Andrey Borodin <x4mmm@yandex-team.ru>
Discussion: https://postgr.es/m/CAH2-WzmD+jUBOpFS2jrnqqrdPSAjoxqyL9FPKaE1BtnY=8Nntg@mail.gmail.com

src/backend/access/nbtree/nbtsearch.c
src/test/modules/nbtree/Makefile
src/test/modules/nbtree/expected/backwards-scan-concurrent-splits.out [new file with mode: 0644]
src/test/modules/nbtree/meson.build
src/test/modules/nbtree/specs/backwards-scan-concurrent-splits.spec [new file with mode: 0644]

index 8eb245e2fcb45e1a239fc1b1b6d7db9b39b67427..5964bc9195e1defe54eb53a0a0a3920a0cd77c95 100644 (file)
@@ -1984,6 +1984,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("nbtree-walk-left", NULL);
+#endif
+
        for (;;)
        {
                Buffer          buf;
@@ -2018,6 +2023,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("nbtree-walk-left-step-right", NULL);
+#endif
+
                        /* step right */
                        *blkno = opaque->btpo_next;
                        buf = _bt_relandgetbuf(rel, buf, *blkno, BT_READ);
@@ -2035,6 +2046,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("nbtree-walk-left-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
@@ -2082,6 +2098,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("nbtree-walk-left-restart", NULL);
+#endif
        }
 
        return InvalidBuffer;
index 72b42d32e23acee25f7a468d2fc950e9f6639a91..20a1ca6a92ba473f4f9f8aaff4bb8eb230a17628 100644 (file)
@@ -5,7 +5,8 @@ EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck
 REGRESS = nbtree_half_dead_pages \
        nbtree_incomplete_splits
 
-ISOLATION = predicate-empty-index
+ISOLATION = backwards-scan-concurrent-splits \
+       predicate-empty-index
 
 ifdef USE_PGXS
 PG_CONFIG = pg_config
diff --git a/src/test/modules/nbtree/expected/backwards-scan-concurrent-splits.out b/src/test/modules/nbtree/expected/backwards-scan-concurrent-splits.out
new file mode 100644 (file)
index 0000000..906c10d
--- /dev/null
@@ -0,0 +1,304 @@
+Parsed test spec with 2 sessions
+
+starting permutation: b_attach b_scan i_insert_dups i_detach b_detach
+step b_attach: 
+  SELECT injection_points_attach('nbtree-walk-left', 'wait');
+  SELECT injection_points_attach('nbtree-walk-left-step-right', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-restart', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-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 AND pg_backend_pid() <> 0
+              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('nbtree-walk-left');
+  SELECT injection_points_wakeup('nbtree-walk-left');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+scan_session: NOTICE:  notice triggered for injection point nbtree-walk-left-step-right
+step b_scan: <... completed>
+col
+---
+601
+501
+401
+301
+201
+101
+  1
+(7 rows)
+
+step b_detach: 
+  SELECT injection_points_detach('nbtree-walk-left-step-right');
+  SELECT injection_points_detach('nbtree-walk-left-restart');
+  SELECT injection_points_detach('nbtree-walk-left-deleted');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+
+starting permutation: b_attach b_scan i_insert i_detach b_detach
+step b_attach: 
+  SELECT injection_points_attach('nbtree-walk-left', 'wait');
+  SELECT injection_points_attach('nbtree-walk-left-step-right', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-restart', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-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 AND pg_backend_pid() <> 0
+              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('nbtree-walk-left');
+  SELECT injection_points_wakeup('nbtree-walk-left');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+scan_session: NOTICE:  notice triggered for injection point nbtree-walk-left-step-right
+scan_session: NOTICE:  notice triggered for injection point nbtree-walk-left-step-right
+scan_session: NOTICE:  notice triggered for injection point nbtree-walk-left-step-right
+scan_session: NOTICE:  notice triggered for injection point nbtree-walk-left-step-right
+scan_session: NOTICE:  notice triggered for injection point nbtree-walk-left-restart
+step b_scan: <... completed>
+col
+---
+601
+501
+401
+301
+201
+101
+  1
+(7 rows)
+
+step b_detach: 
+  SELECT injection_points_detach('nbtree-walk-left-step-right');
+  SELECT injection_points_detach('nbtree-walk-left-restart');
+  SELECT injection_points_detach('nbtree-walk-left-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('nbtree-walk-left', 'wait');
+  SELECT injection_points_attach('nbtree-walk-left-step-right', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-restart', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-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 AND pg_backend_pid() <> 0
+              ORDER BY col DESC; <waiting ...>
+step vacuum_tbl: VACUUM backwards_scan_tbl;
+step i_detach: 
+  SELECT injection_points_detach('nbtree-walk-left');
+  SELECT injection_points_wakeup('nbtree-walk-left');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+scan_session: NOTICE:  notice triggered for injection point nbtree-walk-left-step-right
+step b_scan: <... completed>
+col
+---
+601
+(1 row)
+
+step b_detach: 
+  SELECT injection_points_detach('nbtree-walk-left-step-right');
+  SELECT injection_points_detach('nbtree-walk-left-restart');
+  SELECT injection_points_detach('nbtree-walk-left-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('nbtree-walk-left', 'wait');
+  SELECT injection_points_attach('nbtree-walk-left-restart', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-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 AND pg_backend_pid() <> 0
+                  ORDER BY col DESC; <waiting ...>
+step vacuum_tbl: VACUUM backwards_scan_tbl;
+step i_detach: 
+  SELECT injection_points_detach('nbtree-walk-left');
+  SELECT injection_points_wakeup('nbtree-walk-left');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+scan_session: NOTICE:  notice triggered for injection point nbtree-walk-left-deleted
+scan_session: NOTICE:  notice triggered for injection point nbtree-walk-left-restart
+step b_scan_999: <... completed>
+col
+---
+301
+201
+101
+  1
+(4 rows)
+
+step b_detach_nosr: 
+  SELECT injection_points_detach('nbtree-walk-left-restart');
+  SELECT injection_points_detach('nbtree-walk-left-deleted');
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
+injection_points_detach
+-----------------------
+                       
+(1 row)
+
index 8cf861cb2fc5daa7beed39e74803b1045a5be68f..b5dc026392eacb0c0b86a8399dc6df1044b0ae45 100644 (file)
@@ -16,7 +16,9 @@ tests += {
   },
   'isolation': {
     'specs': [
+      'backwards-scan-concurrent-splits',
       'predicate-empty-index',
     ],
+    'runningcheck': false, # see syscache-update-pruned
   },
 }
diff --git a/src/test/modules/nbtree/specs/backwards-scan-concurrent-splits.spec b/src/test/modules/nbtree/specs/backwards-scan-concurrent-splits.spec
new file mode 100644 (file)
index 0000000..62c0cf2
--- /dev/null
@@ -0,0 +1,123 @@
+# Backwards scan isolation test
+#
+# Backwards scans cannot unreservedly trust their saved left link: by the time
+# the scan follows it, concurrent page splits and/or page deletions may have
+# left it pointing to a page that is no longer the correct page for the scan
+# to read next.  The scan checks for this by verifying that the pointed-to
+# page's right link still points back to the page that the scan just read, and
+# recovers when it doesn't (see nbtree/README for details).
+#
+# Each permutation makes the scan wait "between pages" at the nbtree-walk-left
+# injection point while the concurrent session splits and/or deletes pages,
+# then wakes it, forcing the scan to take one of its recovery paths.  The
+# notice-mode injection points confirm which recovery steps ran.
+#
+# 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 scan_session
+setup {
+  SELECT injection_points_set_local();
+  SET enable_seqscan=off;
+  SET enable_sort=off;
+}
+step b_attach {
+  SELECT injection_points_attach('nbtree-walk-left', 'wait');
+  SELECT injection_points_attach('nbtree-walk-left-step-right', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-restart', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-deleted', 'notice');
+}
+# Variant that doesn't attach to nbtree-walk-left-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('nbtree-walk-left', 'wait');
+  SELECT injection_points_attach('nbtree-walk-left-restart', 'notice');
+  SELECT injection_points_attach('nbtree-walk-left-deleted', 'notice');
+}
+# Note: Both scan variants call parallel restricted pg_backend_pid() so that
+# the scan runs in the leader process under debug_parallel_query
+step b_scan { SELECT col FROM backwards_scan_tbl
+              WHERE col % 100 = 1 AND pg_backend_pid() <> 0
+              ORDER BY col DESC; }
+step b_scan_999 { SELECT col FROM backwards_scan_tbl
+                  WHERE col <= 999 AND col % 100 = 1 AND pg_backend_pid() <> 0
+                  ORDER BY col DESC; }
+step b_detach {
+  SELECT injection_points_detach('nbtree-walk-left-step-right');
+  SELECT injection_points_detach('nbtree-walk-left-restart');
+  SELECT injection_points_detach('nbtree-walk-left-deleted');
+}
+step b_detach_nosr {
+  SELECT injection_points_detach('nbtree-walk-left-restart');
+  SELECT injection_points_detach('nbtree-walk-left-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('nbtree-walk-left');
+  SELECT injection_points_wakeup('nbtree-walk-left');
+}
+
+# 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
+
+# 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
+
+# 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