From 13f6f89bd1101cdf78564ea778d40b2ff68a9a76 Mon Sep 17 00:00:00 2001
From: Josh Curtis <jcurtis825@gmail.com>
Date: Fri, 20 Feb 2026 11:48:34 -0800
Subject: [PATCH v1] Add test to demonstrate bug in SSI

---
 src/backend/storage/lmgr/predicate.c          |  9 ++
 src/test/modules/injection_points/Makefile    |  1 +
 .../expected/predicate-lock-page-split.out    | 86 +++++++++++++++++
 src/test/modules/injection_points/meson.build |  1 +
 .../specs/predicate-lock-page-split.spec      | 92 +++++++++++++++++++
 5 files changed, 189 insertions(+)
 create mode 100644 src/test/modules/injection_points/expected/predicate-lock-page-split.out
 create mode 100644 src/test/modules/injection_points/specs/predicate-lock-page-split.spec

diff --git a/src/backend/storage/lmgr/predicate.c b/src/backend/storage/lmgr/predicate.c
index fe75ead350..de8a84b7e8 100644
--- a/src/backend/storage/lmgr/predicate.c
+++ b/src/backend/storage/lmgr/predicate.c
@@ -212,6 +212,7 @@
 #include "storage/proc.h"
 #include "storage/procarray.h"
 #include "utils/guc_hooks.h"
+#include "utils/injection_point.h"
 #include "utils/rel.h"
 #include "utils/snapmgr.h"
 
@@ -3156,6 +3157,7 @@ PredicateLockPageSplit(Relation relation, BlockNumber oldblkno,
 	 * memory barrier in the LWLock acquisition guarantees that this read
 	 * occurs while the buffer page lock is held.
 	 */
+	INJECTION_POINT("predicate-lock-page-split", NULL);
 	if (!TransactionIdIsValid(PredXact->SxactGlobalXmin))
 		return;
 
@@ -3255,6 +3257,10 @@ SetNewSxactGlobalXmin(void)
 	PredXact->SxactGlobalXmin = InvalidTransactionId;
 	PredXact->SxactGlobalXminCount = 0;
 
+#ifdef USE_INJECTION_POINTS
+	INJECTION_POINT_CACHED("predicate-set-sxact-global-xmin-invalid", NULL);
+#endif
+
 	dlist_foreach(iter, &PredXact->activeList)
 	{
 		SERIALIZABLEXACT *sxact =
@@ -3368,6 +3374,9 @@ ReleasePredicateLocks(bool isCommit, bool isReadOnlySafe)
 		Assert(LocalPredicateLockHash == NULL);
 		return;
 	}
+#ifdef USE_INJECTION_POINTS
+	INJECTION_POINT_LOAD("predicate-set-sxact-global-xmin-invalid");
+#endif
 
 	LWLockAcquire(SerializableXactHashLock, LW_EXCLUSIVE);
 
diff --git a/src/test/modules/injection_points/Makefile b/src/test/modules/injection_points/Makefile
index a41d781f8c..73ed09eaa2 100644
--- a/src/test/modules/injection_points/Makefile
+++ b/src/test/modules/injection_points/Makefile
@@ -14,6 +14,7 @@ REGRESS_OPTS = --dlpath=$(top_builddir)/src/test/regress
 
 ISOLATION = basic \
 	    inplace \
+	    predicate-lock-page-split \
 	    syscache-update-pruned \
 	    heap_lock_update
 
diff --git a/src/test/modules/injection_points/expected/predicate-lock-page-split.out b/src/test/modules/injection_points/expected/predicate-lock-page-split.out
new file mode 100644
index 0000000000..b0a6493519
--- /dev/null
+++ b/src/test/modules/injection_points/expected/predicate-lock-page-split.out
@@ -0,0 +1,86 @@
+Parsed test spec with 5 sessions
+
+starting permutation: s1_begin bump_xmin s2_begin s3_begin s1_insert s2_insert_wait_at_page_split s1_commit_wait_in_SetNewSxactGlobalXmin wakeup_s2_then_s1 s3_insert s3_commit s2_commit verify_order
+step s1_begin: 
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) FROM test_table;
+
+max
+---
+406
+(1 row)
+
+step bump_xmin: 
+    CREATE TABLE tmp_bump_xmin();
+    DROP TABLE tmp_bump_xmin;
+
+step s2_begin: 
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) FROM test_table;
+
+max
+---
+406
+(1 row)
+
+step s3_begin: 
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) FROM test_table;
+
+max
+---
+406
+(1 row)
+
+step s1_insert: 
+    INSERT INTO test_table SELECT max(id) + 1 FROM test_table;
+
+step s2_insert_wait_at_page_split: 
+    SELECT FROM injection_points_set_local();
+    SELECT FROM injection_points_attach('predicate-lock-page-split', 'wait');
+    INSERT INTO test_table SELECT max(id) + 1 FROM test_table;
+ <waiting ...>
+step s1_commit_wait_in_SetNewSxactGlobalXmin: 
+    SELECT injection_points_set_local();
+    SELECT injection_points_attach('predicate-set-sxact-global-xmin-invalid', 'wait');
+    COMMIT;
+ <waiting ...>
+step wakeup_s2_then_s1: 
+    SELECT FROM injection_points_wakeup('predicate-lock-page-split');
+    SELECT FROM injection_points_wakeup('predicate-set-sxact-global-xmin-invalid');
+ <waiting ...>
+step s2_insert_wait_at_page_split: <... completed>
+step s1_commit_wait_in_SetNewSxactGlobalXmin: <... completed>
+injection_points_set_local
+--------------------------
+                          
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s3_insert: 
+    INSERT INTO test_table SELECT max(id) + 1 FROM test_table;
+
+step wakeup_s2_then_s1: <... completed>
+step s3_commit: 
+    COMMIT;
+
+step s2_commit: 
+    COMMIT;
+
+ERROR:  could not serialize access due to read/write dependencies among transactions
+step verify_order: 
+    select id from test_table order by id desc limit 5;
+
+ id
+---
+407
+407
+406
+405
+404
+(5 rows)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fcc8541451..6ff7005a9b 100644
--- a/src/test/modules/injection_points/meson.build
+++ b/src/test/modules/injection_points/meson.build
@@ -45,6 +45,7 @@ tests += {
     'specs': [
       'basic',
       'inplace',
+      'predicate-lock-page-split',
       'syscache-update-pruned',
       'heap_lock_update',
     ],
diff --git a/src/test/modules/injection_points/specs/predicate-lock-page-split.spec b/src/test/modules/injection_points/specs/predicate-lock-page-split.spec
new file mode 100644
index 0000000000..1566090272
--- /dev/null
+++ b/src/test/modules/injection_points/specs/predicate-lock-page-split.spec
@@ -0,0 +1,92 @@
+# Test for race condition in PredicateLockPageSplit
+#
+# When SetNewSxactGlobalXmin() temporarily sets SxactGlobalXmin to
+# InvalidTransactionId, a concurrent PredicateLockPageSplit() will skip
+# transferring SIREAD locks to the new page. This lets a third transaction
+# insert onto the new page without SSI detecting the conflict.
+setup
+{
+    CREATE EXTENSION IF NOT EXISTS injection_points;
+    DROP TABLE IF EXISTS test_table;
+
+    CREATE TABLE test_table (id int);
+    CREATE INDEX test_table_id_idx ON test_table USING btree (id);
+    INSERT INTO test_table SELECT i FROM generate_series(1, 406) i;
+}
+
+
+teardown
+{
+    DROP TABLE IF EXISTS test_table;
+    DROP EXTENSION IF EXISTS injection_points;
+}
+
+session s0
+step bump_xmin {
+    CREATE TABLE tmp_bump_xmin();
+    DROP TABLE tmp_bump_xmin;
+}
+step verify_order {
+    select id from test_table order by id desc limit 5;
+}
+session s1
+step s1_begin {
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) FROM test_table;
+}
+step s1_insert {
+    INSERT INTO test_table SELECT max(id) + 1 FROM test_table;
+}
+step s1_commit_wait_in_SetNewSxactGlobalXmin {
+    SELECT injection_points_set_local();
+    SELECT injection_points_attach('predicate-set-sxact-global-xmin-invalid', 'wait');
+    COMMIT;
+}
+
+session s2
+step s2_begin {
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) FROM test_table;
+}
+step s2_insert_wait_at_page_split {
+    SELECT FROM injection_points_set_local();
+    SELECT FROM injection_points_attach('predicate-lock-page-split', 'wait');
+    INSERT INTO test_table SELECT max(id) + 1 FROM test_table;
+}
+step s2_commit {
+    COMMIT;
+}
+
+session s3
+step s3_begin {
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) FROM test_table;
+}
+step s3_insert {
+    INSERT INTO test_table SELECT max(id) + 1 FROM test_table;
+}
+step s3_commit {
+    COMMIT;
+}
+
+session s4
+step wakeup_s2_then_s1 {
+    SELECT FROM injection_points_wakeup('predicate-lock-page-split');
+    SELECT FROM injection_points_wakeup('predicate-set-sxact-global-xmin-invalid');
+}
+
+# s1_begin: Start transaction 1, read max(id) = 406
+# bump_xmin: Advance xmin so s2 and s3 get a higher xmin than s1
+# s2_begin, s3_begin: Start transactions 2 and 3, read max(id) = 406
+#
+# at this point s1, s2, and s3 are all concurrent
+#
+# s1_insert: s1 inserts 407 into index page 1
+# s2_insert_wait_at_page_split: s2 inserts 407, triggers a page split and waits at the start of PredicateLockPageSplit
+# s1_commit_wait_in_SetNewSxactGlobalXmin: s1 commits then waits after setting SxactGlobalXmin to InvalidTransactionId in SetNewSxactGlobalXmin
+# wakeup_s2_then_s1: s2 wakes up, sees InvalidTransactionId, and skips transferring SIREAD locks to the new page
+#                    then s1 wakes up and finishes committing
+# s3_insert: s3 inserts 407 into index page 2 (no SIREAD locks were transferred = bug)
+# s3_commit: s3 commits successfully (should have been aborted)
+# s2_commit: s2 tries to commit but aborts due to serialization failure (cycle with s1)
+permutation s1_begin bump_xmin s2_begin s3_begin s1_insert s2_insert_wait_at_page_split s1_commit_wait_in_SetNewSxactGlobalXmin wakeup_s2_then_s1(s2_insert_wait_at_page_split,s1_commit_wait_in_SetNewSxactGlobalXmin) s3_insert s3_commit s2_commit verify_order
-- 
2.51.2

