Add a test for incomplete splits in B-tree indexes
authorHeikki Linnakangas <heikki.linnakangas@iki.fi>
Tue, 2 Dec 2025 19:10:47 +0000 (21:10 +0200)
committerHeikki Linnakangas <heikki.linnakangas@iki.fi>
Tue, 2 Dec 2025 19:10:47 +0000 (21:10 +0200)
To increase our test coverage in general, and because I will add onto
this in the next commit to also test amcheck with incomplete splits.

This is copied from the similar test we had for GIN indexes. B-tree's
incomplete splits work similarly to GIN's, so with small changes, the
same test works for B-tree too.

Reviewed-by: Peter Geoghegan <pg@bowt.ie>
Discussion: https://www.postgresql.org/message-id/abd65090-5336-42cc-b768-2bdd66738404@iki.fi

src/backend/access/nbtree/nbtinsert.c
src/test/modules/meson.build
src/test/modules/nbtree/Makefile [new file with mode: 0644]
src/test/modules/nbtree/expected/nbtree_incomplete_splits.out [new file with mode: 0644]
src/test/modules/nbtree/meson.build [new file with mode: 0644]
src/test/modules/nbtree/sql/nbtree_incomplete_splits.sql [new file with mode: 0644]

index 7c113c007e52beed5773f932d63aa34de4b0d0aa..3a4b791f2ab07b1d10ab8ad3d0ed65da6632f792 100644 (file)
@@ -26,6 +26,7 @@
 #include "miscadmin.h"
 #include "storage/lmgr.h"
 #include "storage/predicate.h"
+#include "utils/injection_point.h"
 
 /* Minimum tree height for application of fastpath optimization */
 #define BTREE_FASTPATH_MIN_LEVEL   2
@@ -1239,6 +1240,13 @@ _bt_insertonpg(Relation rel,
         * page.
         *----------
         */
+#ifdef USE_INJECTION_POINTS
+       if (P_ISLEAF(opaque))
+           INJECTION_POINT("nbtree-leave-leaf-split-incomplete", NULL);
+       else
+           INJECTION_POINT("nbtree-leave-internal-split-incomplete", NULL);
+#endif
+
        _bt_insert_parent(rel, heaprel, buf, rbuf, stack, isroot, isonly);
    }
    else
@@ -2285,6 +2293,7 @@ _bt_finish_split(Relation rel, Relation heaprel, Buffer lbuf, BTStack stack)
    /* Was this the only page on the level before split? */
    wasonly = (P_LEFTMOST(lpageop) && P_RIGHTMOST(rpageop));
 
+   INJECTION_POINT("nbtree-finish-incomplete-split", NULL);
    elog(DEBUG1, "finishing incomplete split of %u/%u",
         BufferGetBlockNumber(lbuf), BufferGetBlockNumber(rbuf));
 
index f5114469b92e6ea2267b47b585ab04fd1a531742..cc57461e59a1fccf38e1c478bef3efb9c56365e9 100644 (file)
@@ -10,6 +10,7 @@ subdir('index')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('nbtree')
 subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile
new file mode 100644 (file)
index 0000000..34946a8
--- /dev/null
@@ -0,0 +1,28 @@
+# src/test/modules/nbtree/Makefile
+
+EXTRA_INSTALL = src/test/modules/injection_points
+
+REGRESS = nbtree_incomplete_splits
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/nbtree
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+
+# XXX: This test is conditional on enable_injection_points in the
+# parent Makefile, so we should never get here in the first place if
+# injection points are not enabled. But the buildfarm 'misc-check'
+# step doesn't pay attention to the if-condition in the parent
+# Makefile. To work around that, disable running the test here too.
+ifeq ($(enable_injection_points),yes)
+include $(top_srcdir)/contrib/contrib-global.mk
+else
+check:
+   @echo "injection points are disabled in this build"
+endif
+
+endif
diff --git a/src/test/modules/nbtree/expected/nbtree_incomplete_splits.out b/src/test/modules/nbtree/expected/nbtree_incomplete_splits.out
new file mode 100644 (file)
index 0000000..88e87e8
--- /dev/null
@@ -0,0 +1,179 @@
+--
+-- Test incomplete splits in B-tree indexes.
+--
+-- We use a test table with integers from 1 to :next_i.  Each integer
+-- occurs exactly once, no gaps or duplicates, although the index does
+-- contain some duplicates because some of the inserting transactions
+-- are rolled back during the test.  The exact contents of the table
+-- depend on the physical layout of the index, which in turn depends
+-- at least on the block size, so instead of checking the exact
+-- contents, we check those invariants.  :next_i psql variable is
+-- maintained at all times to hold the last inserted integer + 1.
+--
+-- This uses injection points to cause errors that leave some page
+-- splits in "incomplete" state
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+reset client_min_messages;
+-- Make all injection points local to this process, for concurrency.
+SELECT injection_points_set_local();
+ injection_points_set_local 
+----------------------------
+(1 row)
+
+-- Use the index for all the queries
+set enable_seqscan=off;
+-- Print a NOTICE whenever an incomplete split gets fixed
+SELECT injection_points_attach('nbtree-finish-incomplete-split', 'notice');
+ injection_points_attach 
+-------------------------
+(1 row)
+
+--
+-- First create the test table and some helper functions
+--
+create table nbtree_incomplete_splits(i int4) with (autovacuum_enabled = off);
+create index nbtree_incomplete_splits_i_idx on nbtree_incomplete_splits using btree (i);
+-- Inserts 'n' rows to the test table. Pass :next_i as the first
+-- argument, returns the new value for :next_i.
+create function insert_n(first_i int, n int) returns int language plpgsql as $$
+begin
+  insert into nbtree_incomplete_splits select g from generate_series(first_i, first_i + n - 1) as g;
+  return first_i + n;
+end;
+$$;
+-- Inserts to the table until an insert fails. Like insert_n(), returns the
+-- new value for :next_i.
+create function insert_until_fail(next_i int, step int default 1) returns int language plpgsql as $$
+declare
+  i integer;
+begin
+  -- Insert rows in batches of 'step' rows each, until an error occurs.
+  i := 0;
+  loop
+    begin
+      select insert_n(next_i, step) into next_i;
+    exception when others then
+      raise notice 'failed with: %', sqlerrm;
+      exit;
+    end;
+
+    -- The caller is expected to set an injection point that eventually
+    -- causes an error. But bail out if still no error after 10000
+    -- attempts, so that we don't get stuck in an infinite loop.
+    i := i + 1;
+    if i = 10000 then
+      raise 'no error on inserts after % iterations', i;
+    end if;
+  end loop;
+
+  return next_i;
+end;
+$$;
+-- Check the invariants.
+create function verify(next_i int) returns bool language plpgsql as $$
+declare
+  c integer;
+begin
+  -- Perform a scan over the trailing part of the index, where the
+  -- possible incomplete splits are. (We don't check the whole table,
+  -- because that'd be pretty slow.)
+  --
+  -- Find all rows that overlap with the last 200 inserted integers. Or
+  -- the next 100, which shouldn't exist.
+  select count(*) into c from nbtree_incomplete_splits where i between next_i - 200 and next_i + 100;
+  if c <> 200 then
+    raise 'unexpected count % ', c;
+  end if;
+  return true;
+end;
+$$;
+-- Insert one array to get started.
+select insert_n(1, 1000) as next_i
+\gset
+select verify(:next_i);
+ verify 
+--------
+ t
+(1 row)
+
+--
+-- Test incomplete leaf split
+--
+SELECT injection_points_attach('nbtree-leave-leaf-split-incomplete', 'error');
+ injection_points_attach 
+-------------------------
+(1 row)
+
+select insert_until_fail(:next_i) as next_i
+\gset
+NOTICE:  failed with: error triggered for injection point nbtree-leave-leaf-split-incomplete
+SELECT injection_points_detach('nbtree-leave-leaf-split-incomplete');
+ injection_points_detach 
+-------------------------
+(1 row)
+
+-- Verify that a scan works even though there's an incomplete split
+select verify(:next_i);
+ verify 
+--------
+ t
+(1 row)
+
+-- Insert some more rows, finishing the split
+select insert_n(:next_i, 10) as next_i
+\gset
+NOTICE:  notice triggered for injection point nbtree-finish-incomplete-split
+-- Verify that a scan still works
+select verify(:next_i);
+ verify 
+--------
+ t
+(1 row)
+
+--
+-- Test incomplete internal page split
+--
+SELECT injection_points_attach('nbtree-leave-internal-split-incomplete', 'error');
+ injection_points_attach 
+-------------------------
+(1 row)
+
+select insert_until_fail(:next_i, 100) as next_i
+\gset
+NOTICE:  failed with: error triggered for injection point nbtree-leave-internal-split-incomplete
+SELECT injection_points_detach('nbtree-leave-internal-split-incomplete');
+ injection_points_detach 
+-------------------------
+(1 row)
+
+ -- Verify that a scan works even though there's an incomplete split
+select verify(:next_i);
+ verify 
+--------
+ t
+(1 row)
+
+-- Insert some more rows, finishing the split
+select insert_n(:next_i, 10) as next_i
+\gset
+NOTICE:  notice triggered for injection point nbtree-finish-incomplete-split
+-- Verify that a scan still works
+select verify(:next_i);
+ verify 
+--------
+ t
+(1 row)
+
+SELECT injection_points_detach('nbtree-finish-incomplete-split');
+ injection_points_detach 
+-------------------------
+(1 row)
+
diff --git a/src/test/modules/nbtree/meson.build b/src/test/modules/nbtree/meson.build
new file mode 100644 (file)
index 0000000..efebf30
--- /dev/null
@@ -0,0 +1,16 @@
+# Copyright (c) 2022-2025, PostgreSQL Global Development Group
+
+if not get_option('injection_points')
+  subdir_done()
+endif
+
+tests += {
+  'name': 'nbtree',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'nbtree_incomplete_splits',
+    ],
+  },
+}
diff --git a/src/test/modules/nbtree/sql/nbtree_incomplete_splits.sql b/src/test/modules/nbtree/sql/nbtree_incomplete_splits.sql
new file mode 100644 (file)
index 0000000..0609ed7
--- /dev/null
@@ -0,0 +1,134 @@
+--
+-- Test incomplete splits in B-tree indexes.
+--
+-- We use a test table with integers from 1 to :next_i.  Each integer
+-- occurs exactly once, no gaps or duplicates, although the index does
+-- contain some duplicates because some of the inserting transactions
+-- are rolled back during the test.  The exact contents of the table
+-- depend on the physical layout of the index, which in turn depends
+-- at least on the block size, so instead of checking the exact
+-- contents, we check those invariants.  :next_i psql variable is
+-- maintained at all times to hold the last inserted integer + 1.
+--
+
+-- This uses injection points to cause errors that leave some page
+-- splits in "incomplete" state
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+reset client_min_messages;
+
+-- Make all injection points local to this process, for concurrency.
+SELECT injection_points_set_local();
+
+-- Use the index for all the queries
+set enable_seqscan=off;
+
+-- Print a NOTICE whenever an incomplete split gets fixed
+SELECT injection_points_attach('nbtree-finish-incomplete-split', 'notice');
+
+--
+-- First create the test table and some helper functions
+--
+create table nbtree_incomplete_splits(i int4) with (autovacuum_enabled = off);
+
+create index nbtree_incomplete_splits_i_idx on nbtree_incomplete_splits using btree (i);
+
+-- Inserts 'n' rows to the test table. Pass :next_i as the first
+-- argument, returns the new value for :next_i.
+create function insert_n(first_i int, n int) returns int language plpgsql as $$
+begin
+  insert into nbtree_incomplete_splits select g from generate_series(first_i, first_i + n - 1) as g;
+  return first_i + n;
+end;
+$$;
+
+-- Inserts to the table until an insert fails. Like insert_n(), returns the
+-- new value for :next_i.
+create function insert_until_fail(next_i int, step int default 1) returns int language plpgsql as $$
+declare
+  i integer;
+begin
+  -- Insert rows in batches of 'step' rows each, until an error occurs.
+  i := 0;
+  loop
+    begin
+      select insert_n(next_i, step) into next_i;
+    exception when others then
+      raise notice 'failed with: %', sqlerrm;
+      exit;
+    end;
+
+    -- The caller is expected to set an injection point that eventually
+    -- causes an error. But bail out if still no error after 10000
+    -- attempts, so that we don't get stuck in an infinite loop.
+    i := i + 1;
+    if i = 10000 then
+      raise 'no error on inserts after % iterations', i;
+    end if;
+  end loop;
+
+  return next_i;
+end;
+$$;
+
+-- Check the invariants.
+create function verify(next_i int) returns bool language plpgsql as $$
+declare
+  c integer;
+begin
+  -- Perform a scan over the trailing part of the index, where the
+  -- possible incomplete splits are. (We don't check the whole table,
+  -- because that'd be pretty slow.)
+  --
+  -- Find all rows that overlap with the last 200 inserted integers. Or
+  -- the next 100, which shouldn't exist.
+  select count(*) into c from nbtree_incomplete_splits where i between next_i - 200 and next_i + 100;
+  if c <> 200 then
+    raise 'unexpected count % ', c;
+  end if;
+  return true;
+end;
+$$;
+
+-- Insert one array to get started.
+select insert_n(1, 1000) as next_i
+\gset
+select verify(:next_i);
+
+
+--
+-- Test incomplete leaf split
+--
+SELECT injection_points_attach('nbtree-leave-leaf-split-incomplete', 'error');
+select insert_until_fail(:next_i) as next_i
+\gset
+SELECT injection_points_detach('nbtree-leave-leaf-split-incomplete');
+
+-- Verify that a scan works even though there's an incomplete split
+select verify(:next_i);
+
+-- Insert some more rows, finishing the split
+select insert_n(:next_i, 10) as next_i
+\gset
+-- Verify that a scan still works
+select verify(:next_i);
+
+
+--
+-- Test incomplete internal page split
+--
+SELECT injection_points_attach('nbtree-leave-internal-split-incomplete', 'error');
+select insert_until_fail(:next_i, 100) as next_i
+\gset
+SELECT injection_points_detach('nbtree-leave-internal-split-incomplete');
+
+ -- Verify that a scan works even though there's an incomplete split
+select verify(:next_i);
+
+-- Insert some more rows, finishing the split
+select insert_n(:next_i, 10) as next_i
+\gset
+-- Verify that a scan still works
+select verify(:next_i);
+
+SELECT injection_points_detach('nbtree-finish-incomplete-split');