From c99597016af620c6f527e06b445bed3c04158952 Mon Sep 17 00:00:00 2001
From: Mikhail Nikalayeu <mihailnikalayeu@gmail.com>
Date: Sun, 8 Mar 2026 19:20:02 +0100
Subject: [PATCH v2] [PATCH v2] 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    | 105 ++++++++++++++
 src/test/modules/injection_points/meson.build |   1 +
 .../specs/predicate-lock-page-split.spec      | 128 ++++++++++++++++++
 5 files changed, 244 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 548b4f66470..7eef2ca0418 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"
 #include "utils/wait_event.h"
@@ -3157,6 +3158,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;
 
@@ -3256,6 +3258,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 =
@@ -3369,6 +3375,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 a41d781f8c9..73ed09eaa22 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 00000000000..deeced73d88
--- /dev/null
+++ b/src/test/modules/injection_points/expected/predicate-lock-page-split.out
@@ -0,0 +1,105 @@
+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
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+injection_points_attach
+-----------------------
+                       
+(1 row)
+
+step s1_begin: 
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) > 0 AS locked FROM test_table;
+
+locked
+------
+t     
+(1 row)
+
+step bump_xmin: 
+    DO $$
+    BEGIN
+        PERFORM pg_current_xact_id();
+    END;
+    $$;
+
+step s2_begin: 
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) > 0 AS locked FROM test_table;
+
+locked
+------
+t     
+(1 row)
+
+step s3_begin: 
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) > 0 AS locked FROM test_table;
+
+locked
+------
+t     
+(1 row)
+
+step s1_insert: 
+    INSERT INTO test_table
+    SELECT max(id) + 1
+    FROM test_table;
+
+step s2_insert_wait_at_page_split: 
+    DO $$
+    DECLARE
+        next_id int := (SELECT max(id) + 2 FROM test_table);
+        base_size bigint := pg_relation_size('test_table_id_idx');
+    BEGIN
+        LOOP
+            INSERT INTO test_table VALUES (next_id);
+            EXIT WHEN pg_relation_size('test_table_id_idx') > base_size;
+            next_id := next_id + 1;
+        END LOOP;
+    END;
+    $$;
+ <waiting ...>
+step s1_commit_wait_in_SetNewSxactGlobalXmin: 
+    COMMIT;
+ <waiting ...>
+step wakeup_s2_then_s1: 
+    SELECT injection_points_wakeup('predicate-lock-page-split');
+    SELECT 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>
+step s3_insert: 
+    INSERT INTO test_table VALUES (1000000);
+
+ERROR:  could not serialize access due to read/write dependencies among transactions
+step wakeup_s2_then_s1: <... completed>
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+injection_points_wakeup
+-----------------------
+                       
+(1 row)
+
+step s3_commit: 
+    COMMIT;
+
+step s2_commit: 
+    COMMIT;
+
+ERROR:  could not serialize access due to read/write dependencies among transactions
+step verify: 
+    SELECT EXISTS (SELECT 1 FROM test_table WHERE id = 1000000) AS inserted;
+
+inserted
+--------
+f       
+(1 row)
+
diff --git a/src/test/modules/injection_points/meson.build b/src/test/modules/injection_points/meson.build
index fcc85414515..6ff7005a9bf 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 00000000000..bfd7ecc85c6
--- /dev/null
+++ b/src/test/modules/injection_points/specs/predicate-lock-page-split.spec
@@ -0,0 +1,128 @@
+# Test for race condition in PredicateLockPageSplit
+#
+# When SetNewSxactGlobalXmin() temporarily sets SxactGlobalXmin to
+# InvalidTransactionId, a concurrent PredicateLockPageSplit() can see
+# the invalid value and skip transferring SIREAD locks to the new page.
+# This lets a third transaction insert onto the new page without SSI
+# detecting the rw-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 VALUES (1);
+}
+
+teardown
+{
+    DROP TABLE IF EXISTS test_table;
+    DROP EXTENSION IF EXISTS injection_points;
+}
+
+session s0
+step bump_xmin {
+    DO $$
+    BEGIN
+        PERFORM pg_current_xact_id();
+    END;
+    $$;
+}
+step verify {
+    SELECT EXISTS (SELECT 1 FROM test_table WHERE id = 1000000) AS inserted;
+}
+
+session s1
+setup {
+    SELECT injection_points_set_local();
+    SELECT injection_points_attach('predicate-set-sxact-global-xmin-invalid', 'wait');
+}
+step s1_begin {
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) > 0 AS locked FROM test_table;
+}
+step s1_insert {
+    INSERT INTO test_table
+    SELECT max(id) + 1
+    FROM test_table;
+}
+step s1_commit_wait_in_SetNewSxactGlobalXmin {
+    COMMIT;
+}
+
+session s2
+setup {
+    SELECT injection_points_set_local();
+    SELECT injection_points_attach('predicate-lock-page-split', 'wait');
+}
+step s2_begin {
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) > 0 AS locked FROM test_table;
+}
+step s2_insert_wait_at_page_split {
+    DO $$
+    DECLARE
+        next_id int := (SELECT max(id) + 2 FROM test_table);
+        base_size bigint := pg_relation_size('test_table_id_idx');
+    BEGIN
+        LOOP
+            INSERT INTO test_table VALUES (next_id);
+            EXIT WHEN pg_relation_size('test_table_id_idx') > base_size;
+            next_id := next_id + 1;
+        END LOOP;
+    END;
+    $$;
+}
+step s2_commit {
+    COMMIT;
+}
+
+session s3
+step s3_begin {
+    BEGIN ISOLATION LEVEL SERIALIZABLE;
+    SELECT max(id) > 0 AS locked FROM test_table;
+}
+step s3_insert {
+    INSERT INTO test_table VALUES (1000000);
+}
+step s3_commit {
+    COMMIT;
+}
+
+session s4
+step wakeup_s2_then_s1 {
+    SELECT injection_points_wakeup('predicate-lock-page-split');
+    SELECT injection_points_wakeup('predicate-set-sxact-global-xmin-invalid');
+}
+
+# s1_begin: s1 reads from the table, establishing SIREAD locks on the index
+# bump_xmin: advance xmin so s2/s3 get a higher xmin than s1
+# s2_begin, s3_begin: s2 and s3 read from the table (same snapshot as s1)
+#
+# s1_insert: s1 inserts max(id)+1
+# s2_insert_wait_at_page_split: s2 inserts ascending values until a real
+#   btree page split happens, then waits in PredicateLockPageSplit before
+#   checking SxactGlobalXmin
+# s1_commit_wait_in_SetNewSxactGlobalXmin: after s2 is already waiting,
+#   s1 commits and waits after SetNewSxactGlobalXmin sets SxactGlobalXmin
+#   to InvalidTransactionId
+# wakeup_s2_then_s1: wake s2 (sees InvalidTransactionId, skips SIREAD
+#   lock transfer), then wake s1
+# s3_insert: s3 inserts 1000000 onto the new page (no SIREAD locks = bug)
+# s3_commit: s3 commits (should have been aborted by SSI)
+# s2_commit: s2 aborts due to serialization failure
+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
-- 
2.43.0

