SERIALIZABLE can commit write skew after SSI summarization

From: Jacob Brazeal <jacob(dot)brazeal(at)gmail(dot)com>
To: PostgreSQL Hackers <pgsql-hackers(at)lists(dot)postgresql(dot)org>
Subject: SERIALIZABLE can commit write skew after SSI summarization
Date: 2026-08-01 23:59:18
Message-ID: CA+COZaCtK=UQbeQwdAoRw27J+58bJBC+yNHP4OH2+y_t2UtFAg@mail.gmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hi,

I found a reproducible case where SERIALIZABLE commits a non-serializable
write-skew schedule after SSI summarizes a committed transaction's SIREAD
locks into OldCommittedSxact.

I reproduced this on PostgreSQL 17.10, 18.4, and current master, although
it appears to date back to 9.1.

Setup:

CREATE TABLE t(id int PRIMARY KEY, v int);
INSERT INTO t VALUES (1, 0), (2, 0);

Let's say our application rule is that a transaction may set its row to 1
only if no row is already 1:

UPDATE t
SET v = 1
WHERE id = <n>
AND (SELECT count(*) FROM t WHERE v = 1) = 0;

In either serial order, the second transaction sees one row already set and
does not update, so the final count is 1.

Session 1:

BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM t WHERE v = 1;

Leave this transaction open.

Session 2:

BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM t WHERE v = 1;

UPDATE t
SET v = 1
WHERE id = 1
AND (SELECT count(*) FROM t WHERE v = 1) = 0;

COMMIT;

At this point session 2 is committed, but its SIREAD locks are still
retained because session 1 remains open.

Now run enough short serializable transactions to exhaust the
SERIALIZABLEXACT array and force summarization:

python3 - <<'PY' > /tmp/churn.sql
for i in range(1500):
print("BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT 1; COMMIT;")
PY

psql -d test -f /tmp/churn.sql

The exact threshold depends on MaxBackends. On my stock max_connections =
100 cluster it was between 1400 and 1420 transactions.
MaxBackends = max_connections + autovacuum_worker_slots
+ max_worker_processes + max_wal_senders + 2
(NUM_SPECIAL_WORKER_PROCS)
max_serializable_xacts = (MaxBackends + max_prepared_transactions) * 10

The transfer of session 2's SIREAD locks to the summarization dummy is
visible in pg_locks:

SELECT pid, virtualtransaction, locktype, relation::regclass
FROM pg_locks
WHERE mode = 'SIReadLock'
AND relation = 't'::regclass;

Before summarization, the relevant locks have session 2's pid. Afterwards
they appear with:

pid | virtualtransaction
-----+-------------------
NULL | -1/0

Back in session 1:

UPDATE t
SET v = 1
WHERE id = 2
AND (SELECT count(*) FROM t WHERE v = 1) = 0;

COMMIT;

SELECT count(*) FROM t WHERE v = 1;

Observed after summarization:

count
-------
2

Both transactions commit with no serialization failure.

Controls:

- with no churn, session 1's UPDATE raises 40001;
- with sub-threshold churn, it also raises 40001;
- both serial orders produce a final count of 1;
- once session 2's SIREAD locks move to virtualtransaction = '-1/0',
session 1 commits and the final count is 2.

The control error is:

ERROR: could not serialize access due to read/write dependencies among
transactions
DETAIL: Reason code: Canceled on identification as a pivot, during write.
HINT: The transaction might succeed if retried.

Note - this appears to be a live variant of the DTEST_SUMMARIZE_SERIAL case
referenced here [0]. Starting a new thread since that was not that primary
point of the thread, I think.

[0]
https://www.postgresql.org/message-id/flat/787936C5-4155-4CF9-939D-39DC0EC1C892%40yandex-team.ru#f91d94b1b80a11c9a96b3d6f7c89a11c

Regards,
Jacob

Browse pgsql-hackers by date

  From Date Subject
Previous Message Mihail Nikalayeu 2026-08-01 22:57:00 Re: RI fast path gets cross-type foreign keys wrong