From fd43ff3f82d4edb5df6ca59e13aeeaa12c296da9 Mon Sep 17 00:00:00 2001 From: Andrey Borodin 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; +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)