From 4eef7b6380af90616031637feb0321616abb4fb1 Mon Sep 17 00:00:00 2001 From: Peter Geoghegan 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; +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; +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