| 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.
Regards,
Jacob
| From | Date | Subject | |
|---|---|---|---|
| Previous Message | Mihail Nikalayeu | 2026-08-01 22:57:00 | Re: RI fast path gets cross-type foreign keys wrong |