Re: Write skew observed under serializable isolation

From: Jacob Brazeal <jacob(dot)brazeal(at)gmail(dot)com>
To: PostgreSQL Hackers <pgsql-hackers(at)lists(dot)postgresql(dot)org>
Subject: Re: Write skew observed under serializable isolation
Date: 2026-07-27 06:23:06
Message-ID: CA+COZaA+yXRfn-zN8n5PTiPVSS9OoeQm=cbX9eVMd0TE9P6O7Q@mail.gmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Let me give a similar example that also makes the final sum query incorrect
under serializable isolation.

Same setup:

CREATE TABLE trs (id int, v int);
INSERT INTO trs VALUES (1, 0), (2, 0);

Session 1:

BEGIN ISOLATION LEVEL SERIALIZABLE;
SET enable_seqscan = off;
SELECT sum(v) FROM trs WHERE ctid >= '(0,0)' AND ctid <= '(0,100)';
-- 0

Session 2:

BEGIN ISOLATION LEVEL SERIALIZABLE;
SET enable_seqscan = off;
SELECT sum(v) FROM trs WHERE ctid >= '(0,0)' AND ctid <= '(0,100)';
-- 0

-- session 1
UPDATE trs SET v = 1 + (SELECT sum(v) FROM trs
WHERE ctid >= '(0,0)' AND ctid <= '(0,100)')
WHERE ctid = '(0,1)';

-- session 2
UPDATE trs SET v = 1 + (SELECT sum(v) FROM trs
WHERE ctid >= '(0,0)' AND ctid <= '(0,100)')
WHERE ctid = '(0,2)';

-- session 1
COMMIT;

-- session 2
COMMIT; -- succeeds

Both transactions commit, and the final sum is 2:

SELECT sum(v) FROM trs;
-- 2

Neither serial order produces 2; both produce 3. The serial run is shown at
the end of this mail.

The range read is a Tid Range Scan standalone and as the InitPlan of the
UPDATE:

Update on trs
InitPlan expr_1
-> Aggregate
-> Tid Range Scan on trs trs_1
TID Cond: ((ctid >= '(0,0)'::tid) AND (ctid <=
'(0,100)'::tid))
-> Tid Scan on trs
TID Cond: (ctid = '(0,1)'::tid)

As a control, force the same reads through a sequential scan by setting

SET enable_tidscan = off;

in both sessions instead of enable_seqscan. (Note that merely leaving
enable_seqscan on is not enough: the planner still prefers a Tid Range Scan
for this qual, so a control written that way is not a control.) The
identical
schedule then fails at the second commit with

ERROR: could not serialize access due to read/write dependencies among
transactions

and leaves sum = 1. A Tid Scan control aborts correctly as well, since
heap_fetch() takes PredicateLockTID on each visible tuple it returns.

The cause appears to be the scan-type test in heap_beginscan(), in
src/backend/access/heap/heapam.c:

if (scan->rs_base.rs_flags & (SO_TYPE_SEQSCAN | SO_TYPE_SAMPLESCAN))
{
Assert(snapshot);
PredicateLockRelation(relation, snapshot);
}

SO_TYPE_TIDRANGESCAN is not in the list, so a TID range scan takes no SIREAD
lock at all. The comment just above already describes the rule that covers
this case -- "in a heap scan there is nothing more fine-grained to lock" --
and a TID range scan is a heap scan with no index involved. The conflict-out
edge is still recorded by heap_prepare_pagescan(); what is missing is the
conflict-in edge, so a later writer's CheckForSerializableConflictIn() finds
no read to conflict with and no cycle can be detected.

As mentioned previously, I reproduced this on current master. Tid Range
Scans were introduced in
PostgreSQL 14, the test above is unchanged on master, and the behavior
appears
present in all branches since then.

Finally, for reference, here are the same statements run serially --
session 1
start to finish, then session 2 -- which is what a serializable execution is
required to be equivalent to. Starting again from the setup above:

-- session 1
BEGIN ISOLATION LEVEL SERIALIZABLE;
SET enable_seqscan = off;
SELECT sum(v) FROM trs WHERE ctid >= '(0,0)' AND ctid <= '(0,100)';
-- 0
UPDATE trs SET v = 1 + (SELECT sum(v) FROM trs
WHERE ctid >= '(0,0)' AND ctid <= '(0,100)')
WHERE ctid = '(0,1)';
COMMIT;

-- session 2
BEGIN ISOLATION LEVEL SERIALIZABLE;
SET enable_seqscan = off;
SELECT sum(v) FROM trs WHERE ctid >= '(0,0)' AND ctid <= '(0,100)';
-- 1 <-- sees session 1's write, so it stores 2 rather than 1
UPDATE trs SET v = 1 + (SELECT sum(v) FROM trs
WHERE ctid >= '(0,0)' AND ctid <= '(0,100)')
WHERE ctid = '(0,2)';
COMMIT;

SELECT sum(v) FROM trs;
-- 3

Running session 2 first gives 3 as well, by symmetry. The interleaved result
of 2 is therefore not equivalent to any serial order.

Best,
Jacob Brazeal

On Sun, Jul 26, 2026 at 11:05 PM Jacob Brazeal <jacob(dot)brazeal(at)gmail(dot)com>
wrote:

> A candidate patch to fix is attached.
>

In response to

Browse pgsql-hackers by date

  From Date Subject
Next Message Jakub Wartak 2026-07-27 06:47:47 Re: log_postmaster_stats
Previous Message Jacob Brazeal 2026-07-27 06:05:31 Re: Write skew observed under serializable isolation