diff options
| author | Dean Rasheed | 2026-09-15 10:47:09 +0000 |
|---|---|---|
| committer | Dean Rasheed | 2026-09-15 10:47:09 +0000 |
| commit | 80c9a58b4dc773bb997196e75717829f202f1697 (patch) | |
| tree | 26885c8b166b12754d145d574e75df1e6faee042 | |
| parent | 27ef9097b59bbdd059194bac30708eba518ab4d4 (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.c | 16 | ||||
| -rw-r--r-- | src/test/isolation/expected/insert-conflict-serializable.out | 44 | ||||
| -rw-r--r-- | src/test/isolation/isolation_schedule | 1 | ||||
| -rw-r--r-- | src/test/isolation/specs/insert-conflict-serializable.spec | 49 |
4 files changed, 110 insertions, 0 deletions
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c index a11f43d04e9..040be7eb3c9 100644 --- a/src/backend/executor/execIndexing.c +++ b/src/backend/executor/execIndexing.c @@ -852,7 +852,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 d5caa34e767..bc4cbc01822 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -51,6 +51,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 |
