From fd43ff3f82d4edb5df6ca59e13aeeaa12c296da9 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Mon, 1 Jun 2026 12:31:34 +0500
Subject: [PATCH 1/2] Add isolation tests for ON CONFLICT no-op SSI false
 negatives

INSERT ... ON CONFLICT DO NOTHING, and DO UPDATE with an unsatisfied
WHERE, read the conflicting row but take no SIREAD lock, so a concurrent
UPDATE/DELETE of it can escape write-skew detection under SERIALIZABLE.
Each spec contrasts a plain-SELECT control with the ON CONFLICT variant.

The expected output encodes the serializable result (one transaction
cancelled), so these tests fail until the accompanying fix is applied.
---
 .../serializable-onconflict-do-nothing.out    | 68 ++++++++++++++++++
 .../serializable-onconflict-noop-update.out   | 69 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  3 +
 .../serializable-onconflict-do-nothing.spec   | 42 +++++++++++
 .../serializable-onconflict-noop-update.spec  | 42 +++++++++++
 5 files changed, 224 insertions(+)
 create mode 100644 src/test/isolation/expected/serializable-onconflict-do-nothing.out
 create mode 100644 src/test/isolation/expected/serializable-onconflict-noop-update.out
 create mode 100644 src/test/isolation/specs/serializable-onconflict-do-nothing.spec
 create mode 100644 src/test/isolation/specs/serializable-onconflict-noop-update.spec

diff --git a/src/test/isolation/expected/serializable-onconflict-do-nothing.out b/src/test/isolation/expected/serializable-onconflict-do-nothing.out
new file mode 100644
index 00000000000..45ef582465d
--- /dev/null
+++ b/src/test/isolation/expected/serializable-onconflict-do-nothing.out
@@ -0,0 +1,68 @@
+Parsed test spec with 3 sessions
+
+starting permutation: reset s1read s2r2 s1w2 s2w1 s1c s2c ck
+step reset: DELETE FROM noc; INSERT INTO noc VALUES (1, 0), (2, 0);
+step s1read: SELECT v FROM noc WHERE k = 1;
+v
+-
+0
+(1 row)
+
+step s2r2: SELECT v FROM noc WHERE k = 2;
+v
+-
+0
+(1 row)
+
+step s1w2: UPDATE noc SET v = 1 WHERE k = 2;
+step s2w1: DELETE FROM noc WHERE k = 1;
+step s1c: COMMIT;
+step s2c: COMMIT;
+ERROR:  could not serialize access due to read/write dependencies among transactions
+step ck: SELECT k, v FROM noc ORDER BY k;
+             SELECT CASE WHEN NOT EXISTS (SELECT 1 FROM noc WHERE k = 1)
+                          AND (SELECT v FROM noc WHERE k = 2) = 1
+                         THEN 'bad_both_committed'
+                         ELSE 'ok_aborted_or_serial' END AS invariant;
+k|v
+-+-
+1|0
+2|1
+(2 rows)
+
+invariant           
+--------------------
+ok_aborted_or_serial
+(1 row)
+
+
+starting permutation: reset s1noop s2r2 s1w2 s2w1 s1c s2c ck
+step reset: DELETE FROM noc; INSERT INTO noc VALUES (1, 0), (2, 0);
+step s1noop: INSERT INTO noc(k, v) VALUES (1, 99) ON CONFLICT (k) DO NOTHING;
+step s2r2: SELECT v FROM noc WHERE k = 2;
+v
+-
+0
+(1 row)
+
+step s1w2: UPDATE noc SET v = 1 WHERE k = 2;
+step s2w1: DELETE FROM noc WHERE k = 1;
+step s1c: COMMIT;
+step s2c: COMMIT;
+ERROR:  could not serialize access due to read/write dependencies among transactions
+step ck: SELECT k, v FROM noc ORDER BY k;
+             SELECT CASE WHEN NOT EXISTS (SELECT 1 FROM noc WHERE k = 1)
+                          AND (SELECT v FROM noc WHERE k = 2) = 1
+                         THEN 'bad_both_committed'
+                         ELSE 'ok_aborted_or_serial' END AS invariant;
+k|v
+-+-
+1|0
+2|1
+(2 rows)
+
+invariant           
+--------------------
+ok_aborted_or_serial
+(1 row)
+
diff --git a/src/test/isolation/expected/serializable-onconflict-noop-update.out b/src/test/isolation/expected/serializable-onconflict-noop-update.out
new file mode 100644
index 00000000000..af8ce068cc3
--- /dev/null
+++ b/src/test/isolation/expected/serializable-onconflict-noop-update.out
@@ -0,0 +1,69 @@
+Parsed test spec with 3 sessions
+
+starting permutation: reset s1read s2r2 s1w2 s2w1 s1c s2c ck
+step reset: DELETE FROM noc; INSERT INTO noc VALUES (1, 0), (2, 0);
+step s1read: SELECT v FROM noc WHERE k = 1;
+v
+-
+0
+(1 row)
+
+step s2r2: SELECT v FROM noc WHERE k = 2;
+v
+-
+0
+(1 row)
+
+step s1w2: UPDATE noc SET v = 1 WHERE k = 2;
+step s2w1: UPDATE noc SET v = 42 WHERE k = 1;
+step s1c: COMMIT;
+step s2c: COMMIT;
+ERROR:  could not serialize access due to read/write dependencies among transactions
+step ck: SELECT k, v FROM noc ORDER BY k;
+             SELECT CASE WHEN (SELECT v FROM noc WHERE k = 1) = 42
+                          AND (SELECT v FROM noc WHERE k = 2) = 1
+                         THEN 'bad_both_committed'
+                         ELSE 'ok_aborted_or_serial' END AS invariant;
+k|v
+-+-
+1|0
+2|1
+(2 rows)
+
+invariant           
+--------------------
+ok_aborted_or_serial
+(1 row)
+
+
+starting permutation: reset s1noop s2r2 s1w2 s2w1 s1c s2c ck
+step reset: DELETE FROM noc; INSERT INTO noc VALUES (1, 0), (2, 0);
+step s1noop: INSERT INTO noc(k, v) VALUES (1, 99) ON CONFLICT (k) DO UPDATE SET v = 100 WHERE noc.v = 42;
+step s2r2: SELECT v FROM noc WHERE k = 2;
+v
+-
+0
+(1 row)
+
+step s1w2: UPDATE noc SET v = 1 WHERE k = 2;
+step s2w1: UPDATE noc SET v = 42 WHERE k = 1; <waiting ...>
+step s1c: COMMIT;
+step s2w1: <... completed>
+ERROR:  could not serialize access due to read/write dependencies among transactions
+step s2c: COMMIT;
+step ck: SELECT k, v FROM noc ORDER BY k;
+             SELECT CASE WHEN (SELECT v FROM noc WHERE k = 1) = 42
+                          AND (SELECT v FROM noc WHERE k = 2) = 1
+                         THEN 'bad_both_committed'
+                         ELSE 'ok_aborted_or_serial' END AS invariant;
+k|v
+-+-
+1|0
+2|1
+(2 rows)
+
+invariant           
+--------------------
+ok_aborted_or_serial
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index 15c33fad4c5..313e8abba6f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -127,3 +127,6 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+
+test: serializable-onconflict-noop-update
+test: serializable-onconflict-do-nothing
diff --git a/src/test/isolation/specs/serializable-onconflict-do-nothing.spec b/src/test/isolation/specs/serializable-onconflict-do-nothing.spec
new file mode 100644
index 00000000000..da8cd10d3f5
--- /dev/null
+++ b/src/test/isolation/specs/serializable-onconflict-do-nothing.spec
@@ -0,0 +1,42 @@
+# Write-skew via INSERT ... ON CONFLICT DO NOTHING with a concurrent delete of
+# the conflicting row under SERIALIZABLE.
+#
+#   s1 examines k=1 through the ON CONFLICT arbiter and writes k=2
+#   s2 reads k=2 and deletes k=1
+#
+# This is a dangerous structure, so SSI must cancel one transaction.
+#
+# The question this exercises: the arbiter's conflict probe is a read that
+# decides the statement's outcome -- exactly like the plain SELECT in the
+# control permutation -- so it must participate in SSI and take an SIREAD lock.
+# Until that read was predicate-locked, only the control was cancelled while
+# the ON CONFLICT DO NOTHING variant committed a non-serializable result.
+
+setup     { CREATE TABLE noc (k int PRIMARY KEY, v int NOT NULL); }
+teardown  { DROP TABLE noc; }
+
+session s1
+setup { BEGIN ISOLATION LEVEL SERIALIZABLE; }
+step s1read { SELECT v FROM noc WHERE k = 1; }
+step s1noop { INSERT INTO noc(k, v) VALUES (1, 99) ON CONFLICT (k) DO NOTHING; }
+step s1w2   { UPDATE noc SET v = 1 WHERE k = 2; }
+step s1c    { COMMIT; }
+
+session s2
+setup { BEGIN ISOLATION LEVEL SERIALIZABLE; }
+step s2r2 { SELECT v FROM noc WHERE k = 2; }
+step s2w1 { DELETE FROM noc WHERE k = 1; }
+step s2c  { COMMIT; }
+
+# Non-transactional helper: resets the rows before each permutation and reports
+# the final committed state plus a serializability invariant.
+session ctl
+step reset { DELETE FROM noc; INSERT INTO noc VALUES (1, 0), (2, 0); }
+step ck    { SELECT k, v FROM noc ORDER BY k;
+             SELECT CASE WHEN NOT EXISTS (SELECT 1 FROM noc WHERE k = 1)
+                          AND (SELECT v FROM noc WHERE k = 2) = 1
+                         THEN 'bad_both_committed'
+                         ELSE 'ok_aborted_or_serial' END AS invariant; }
+
+permutation reset s1read s2r2 s1w2 s2w1 s1c s2c ck
+permutation reset s1noop s2r2 s1w2 s2w1 s1c s2c ck
diff --git a/src/test/isolation/specs/serializable-onconflict-noop-update.spec b/src/test/isolation/specs/serializable-onconflict-noop-update.spec
new file mode 100644
index 00000000000..44a52f4953c
--- /dev/null
+++ b/src/test/isolation/specs/serializable-onconflict-noop-update.spec
@@ -0,0 +1,42 @@
+# Write-skew via INSERT ... ON CONFLICT DO UPDATE with an unsatisfied WHERE
+# (a no-op update) under SERIALIZABLE.
+#
+#   s1 examines k=1 through the ON CONFLICT arbiter and writes k=2
+#   s2 reads k=2 and writes k=1
+#
+# This is a dangerous structure, so SSI must cancel one transaction.
+#
+# The question this exercises: the arbiter's conflict probe is a read that
+# decides the statement's outcome -- exactly like the plain SELECT in the
+# control permutation -- so it must participate in SSI and take an SIREAD lock.
+# Until that read was predicate-locked, only the control was cancelled while
+# the ON CONFLICT no-op variant committed a non-serializable result.
+
+setup     { CREATE TABLE noc (k int PRIMARY KEY, v int NOT NULL); }
+teardown  { DROP TABLE noc; }
+
+session s1
+setup { BEGIN ISOLATION LEVEL SERIALIZABLE; }
+step s1read { SELECT v FROM noc WHERE k = 1; }
+step s1noop { INSERT INTO noc(k, v) VALUES (1, 99) ON CONFLICT (k) DO UPDATE SET v = 100 WHERE noc.v = 42; }
+step s1w2   { UPDATE noc SET v = 1 WHERE k = 2; }
+step s1c    { COMMIT; }
+
+session s2
+setup { BEGIN ISOLATION LEVEL SERIALIZABLE; }
+step s2r2 { SELECT v FROM noc WHERE k = 2; }
+step s2w1 { UPDATE noc SET v = 42 WHERE k = 1; }
+step s2c  { COMMIT; }
+
+# Non-transactional helper: resets the rows before each permutation and reports
+# the final committed state plus a serializability invariant.
+session ctl
+step reset { DELETE FROM noc; INSERT INTO noc VALUES (1, 0), (2, 0); }
+step ck    { SELECT k, v FROM noc ORDER BY k;
+             SELECT CASE WHEN (SELECT v FROM noc WHERE k = 1) = 42
+                          AND (SELECT v FROM noc WHERE k = 2) = 1
+                         THEN 'bad_both_committed'
+                         ELSE 'ok_aborted_or_serial' END AS invariant; }
+
+permutation reset s1read s2r2 s1w2 s2w1 s1c s2c ck
+permutation reset s1noop s2r2 s1w2 s2w1 s1c s2c ck
-- 
2.50.1 (Apple Git-155)

