summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDean Rasheed2026-09-15 10:47:10 +0000
committerDean Rasheed2026-09-15 10:47:10 +0000
commitcbd2406d07818bd786b1b6459ab67c2823d0aadb (patch)
treea702734dd1a386495da1661eb7c6fc27ed9f80b4
parent8032e8e89456319bc0384c322ebf53ffd666d329 (diff)
Fix missing SIREAD lock on the row found by ON CONFLICT.
INSERT ... ON CONFLICT decides what to do based on the conflicting row found by the arbiter index probe, but SSI never saw that read: the probe runs with a dirty snapshot, which predicate locking ignores, and the later fetch of the row uses SnapshotAny. When the statement then writes nothing, as with DO NOTHING, DO UPDATE with a WHERE clause rejecting the row, or DO SELECT, nothing records the read at all. A concurrent writer of that row went unnoticed and write skew could commit at SERIALIZABLE, even though the same schedule with a plain SELECT of the row fails with a serialization error. To fix, read the conflicting tuple again with the query snapshot, right where the probe finds it. The table AM takes the SIREAD lock and checks for a concurrent writer of the tuple as part of that read, both under the buffer lock, so a writer either sees the lock or is seen. A predicate lock by itself acquired separately after the probe could not offer that: a writer passing its conflict check in between would be missed. Doing this in the probe covers every conflict action, including rows that the WHERE clause of DO UPDATE or DO SELECT then rejects. The DO NOTHING and DO UPDATE cases have been broken since ON CONFLICT was added in 9.5; DO SELECT is new in v19. Backpatch to all supported branches. Author: Zsolt Parragi <zsolt.parragi@percona.com> Author: Andrey Borodin <x4mmm@yandex-team.ru> Reported-by: Andrey Borodin <x4mmm@yandex-team.ru> Reported-by: Zsolt Parragi <zsolt.parragi@percona.com> Discussion: https://postgr.es/m/787936C5-4155-4CF9-939D-39DC0EC1C892@yandex-team.ru Discussion: https://postgr.es/m/CAN4CZFM1GkHJkpMeo4G5rxtacVsfeKCJYiik9E9AKX1E9VYQ1w@mail.gmail.com Backpatch-through: 14
-rw-r--r--src/backend/executor/execIndexing.c16
-rw-r--r--src/test/isolation/expected/insert-conflict-serializable.out44
-rw-r--r--src/test/isolation/isolation_schedule1
-rw-r--r--src/test/isolation/specs/insert-conflict-serializable.spec49
4 files changed, 110 insertions, 0 deletions
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index 6a8735edf7f..ce9d04186bc 100644
--- a/src/backend/executor/execIndexing.c
+++ b/src/backend/executor/execIndexing.c
@@ -835,7 +835,23 @@ retry:
{
conflict = true;
if (conflictTid)
+ {
*conflictTid = existing_slot->tts_tid;
+
+ /*
+ * The conflicting tuple decides the outcome of INSERT ... ON
+ * CONFLICT, so for SSI purposes it has been read, even when
+ * nothing gets written afterwards. The dirty snapshot used
+ * by the scan is not an MVCC snapshot, so SSI ignored that
+ * read. Read the tuple again with the query snapshot to
+ * record it. The result is of no interest here, the caller
+ * checks visibility itself.
+ */
+ if (IsolationIsSerializable())
+ (void) table_tuple_fetch_row_version(heap, conflictTid,
+ estate->es_snapshot,
+ existing_slot);
+ }
break;
}
diff --git a/src/test/isolation/expected/insert-conflict-serializable.out b/src/test/isolation/expected/insert-conflict-serializable.out
new file mode 100644
index 00000000000..6c4abe671f5
--- /dev/null
+++ b/src/test/isolation/expected/insert-conflict-serializable.out
@@ -0,0 +1,44 @@
+Parsed test spec with 2 sessions
+
+starting permutation: ioc_nothing1 count2 delete2 insert1 c1 c2
+step ioc_nothing1: INSERT INTO ioc_a VALUES (1, 99) ON CONFLICT (key) DO NOTHING;
+step count2: SELECT count(*) FROM ioc_b;
+count
+-----
+ 0
+(1 row)
+
+step delete2: DELETE FROM ioc_a WHERE key = 1;
+step insert1: INSERT INTO ioc_b VALUES (1, 10);
+step c1: COMMIT;
+step c2: COMMIT;
+ERROR: could not serialize access due to read/write dependencies among transactions
+
+starting permutation: ioc_nothing1 count2 update2 insert1 c1 c2
+step ioc_nothing1: INSERT INTO ioc_a VALUES (1, 99) ON CONFLICT (key) DO NOTHING;
+step count2: SELECT count(*) FROM ioc_b;
+count
+-----
+ 0
+(1 row)
+
+step update2: UPDATE ioc_a SET val = 1 WHERE key = 1;
+step insert1: INSERT INTO ioc_b VALUES (1, 10);
+step c1: COMMIT;
+step c2: COMMIT;
+ERROR: could not serialize access due to read/write dependencies among transactions
+
+starting permutation: ioc_update1_where count2 update2 insert1 c1 c2
+step ioc_update1_where: INSERT INTO ioc_a VALUES (1, 99) ON CONFLICT (key) DO UPDATE SET val = 99 WHERE ioc_a.val > 100;
+step count2: SELECT count(*) FROM ioc_b;
+count
+-----
+ 0
+(1 row)
+
+step update2: UPDATE ioc_a SET val = 1 WHERE key = 1; <waiting ...>
+step insert1: INSERT INTO ioc_b VALUES (1, 10);
+step c1: COMMIT;
+step update2: <... completed>
+ERROR: could not serialize access due to read/write dependencies among transactions
+step c2: COMMIT;
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index 852d996e88a..10baaf13a3f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -50,6 +50,7 @@ test: insert-conflict-do-update-2
test: insert-conflict-do-update-3
test: insert-conflict-do-update-4
test: insert-conflict-specconflict
+test: insert-conflict-serializable
test: merge-insert-update
test: merge-delete
test: merge-update
diff --git a/src/test/isolation/specs/insert-conflict-serializable.spec b/src/test/isolation/specs/insert-conflict-serializable.spec
new file mode 100644
index 00000000000..e4fb60b7ac9
--- /dev/null
+++ b/src/test/isolation/specs/insert-conflict-serializable.spec
@@ -0,0 +1,49 @@
+# INSERT ... ON CONFLICT at SERIALIZABLE
+#
+# The conflicting row decides the outcome of the statement, so it counts
+# as a read for SSI purposes, whether or not the statement then writes
+# anything: a concurrent transaction writing that row must create a
+# rw-antidependency. These permutations build the classic write-skew
+# cycle: s1 reads a and writes b, while s2 reads b and writes a. One of
+# the two transactions must fail with a serialization error.
+
+setup
+{
+ CREATE TABLE ioc_a (key int PRIMARY KEY, val int);
+ CREATE TABLE ioc_b (key int PRIMARY KEY, val int);
+ INSERT INTO ioc_a VALUES (1, 0);
+}
+
+teardown
+{
+ DROP TABLE ioc_a, ioc_b;
+}
+
+session s1
+setup
+{
+ BEGIN ISOLATION LEVEL SERIALIZABLE;
+}
+step ioc_nothing1 { INSERT INTO ioc_a VALUES (1, 99) ON CONFLICT (key) DO NOTHING; }
+step ioc_update1_where { INSERT INTO ioc_a VALUES (1, 99) ON CONFLICT (key) DO UPDATE SET val = 99 WHERE ioc_a.val > 100; }
+step insert1 { INSERT INTO ioc_b VALUES (1, 10); }
+step c1 { COMMIT; }
+
+session s2
+setup
+{
+ BEGIN ISOLATION LEVEL SERIALIZABLE;
+}
+step count2 { SELECT count(*) FROM ioc_b; }
+step update2 { UPDATE ioc_a SET val = 1 WHERE key = 1; }
+step delete2 { DELETE FROM ioc_a WHERE key = 1; }
+step c2 { COMMIT; }
+
+# DO NOTHING skips the insert because of the existing row, which s2 then
+# deletes or updates: s2 must fail to commit
+permutation ioc_nothing1 count2 delete2 insert1 c1 c2
+permutation ioc_nothing1 count2 update2 insert1 c1 c2
+
+# DO UPDATE with a WHERE clause rejecting the existing row writes nothing,
+# but the row still decided the outcome: s2 must fail
+permutation ioc_update1_where count2 update2 insert1 c1 c2