From: Peter Geoghegan Date: Sat, 25 Jul 2026 18:15:33 +0000 (-0400) Subject: Add tests for nbtree empty index predicate locking. X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=62c05d6f2fa64cce44e57871b4cfcd7b34589fcf;p=thirdparty%2Fpostgresql.git Add tests for nbtree empty index predicate locking. Add coverage for predicate locking of completely empty nbtree indexes, where we must predicate lock the entire relation (instead of some individual leaf page). Both paths that can find the index empty (and must consider whether it's still empty after PredicateLockRelation returns) are covered by a new isolation test that uses injection points. Catalog relation scans skip the injection points. The waiting session runs catalog queries of its own after arming the (session-local) points, and could otherwise suspend itself with nothing lined up to wake it. Follow-up to bugfix commits ce3f19e2 (the _bt_endpoint fix) and f9b7fc65 (the _bt_first/_bt_search fix). Author: Peter Geoghegan Discussion: https://postgr.es/m/CAH2-WzkNoTn3yXY0iGkSuavJ+sL8EROf+kitW+_2v2tJVWuKmA@mail.gmail.com --- diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c index dfcdd2d4cec..8eb245e2fcb 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("nbtree-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("nbtree-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/nbtree/Makefile b/src/test/modules/nbtree/Makefile index eec264b16a4..72b42d32e23 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 = predicate-empty-index + ifdef USE_PGXS PG_CONFIG = pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) diff --git a/src/test/modules/nbtree/expected/predicate-empty-index.out b/src/test/modules/nbtree/expected/predicate-empty-index.out new file mode 100644 index 00000000000..455988e1c0b --- /dev/null +++ b/src/test/modules/nbtree/expected/predicate-empty-index.out @@ -0,0 +1,87 @@ +Parsed test spec with 2 sessions + +starting permutation: 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_scan_first: SELECT id FROM ssi_btree WHERE id = 2 AND pg_backend_pid() <> 0; +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('nbtree-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('nbtree-first-empty'); + SELECT injection_points_detach('nbtree-endpoint-empty'); + +injection_points_detach +----------------------- + +(1 row) + +injection_points_detach +----------------------- + +(1 row) + + +starting permutation: 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_scan_endpoint: SELECT id FROM ssi_btree WHERE pg_backend_pid() <> 0 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('nbtree-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('nbtree-first-empty'); + SELECT injection_points_detach('nbtree-endpoint-empty'); + +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 209c3323b71..8cf861cb2fc 100644 --- a/src/test/modules/nbtree/meson.build +++ b/src/test/modules/nbtree/meson.build @@ -14,4 +14,9 @@ tests += { 'nbtree_incomplete_splits', ], }, + 'isolation': { + 'specs': [ + 'predicate-empty-index', + ], + }, } diff --git a/src/test/modules/nbtree/specs/predicate-empty-index.spec b/src/test/modules/nbtree/specs/predicate-empty-index.spec new file mode 100644 index 00000000000..bfcb8bd1ab8 --- /dev/null +++ b/src/test/modules/nbtree/specs/predicate-empty-index.spec @@ -0,0 +1,73 @@ +# 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 { + BEGIN ISOLATION LEVEL SERIALIZABLE; + SET LOCAL enable_seqscan = off; + SET LOCAL enable_bitmapscan = off; + SELECT injection_points_set_local(); + SELECT injection_points_attach('nbtree-first-empty', 'wait'); + SELECT injection_points_attach('nbtree-endpoint-empty', 'wait'); +} +# 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 AND pg_backend_pid() <> 0; } +# Scan without useful insertion scan keys: starts at _bt_endpoint(). +step s1_scan_endpoint { SELECT id FROM ssi_btree WHERE pg_backend_pid() <> 0 ORDER BY id; } +step s1_insert { INSERT INTO ssi_btree VALUES (1); } +step s1_commit { COMMIT; } + +# Note: Both scan variants call parallel restricted pg_backend_pid() so that +# the scan runs in the leader process under debug_parallel_query + +session s2 +setup { 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('nbtree-first-empty'); } +step s2_wakeup_endpoint { SELECT injection_points_wakeup('nbtree-endpoint-empty'); } +step s2_detach { + SELECT injection_points_detach('nbtree-first-empty'); + SELECT injection_points_detach('nbtree-endpoint-empty'); +} + +# _bt_first()/_bt_search() path +permutation s1_scan_first + s2_scan + s2_insert + s2_commit + s2_wakeup_first + s1_insert + s1_commit + s2_detach + +# _bt_endpoint() path +permutation s1_scan_endpoint + s2_scan + s2_insert + s2_commit + s2_wakeup_endpoint + s1_insert + s1_commit + s2_detach