Ross Coded Classes

Programming tips with no rose colored glasses.

Locking on rows or values that don’t exist in PostgreSQL

Image from https://www.pexels.com/photo/door-green-closed-lock-4291/

I came across a situation where for concurrency of two requests trying to make the same resource in the database, I wanted the other requests to block until the resource was inserted and then continue without error.

A similar problem is perhaps trying to determine the largest invoice number in a system and acquire the next number for the invoice you are inserting, without an error because concurrent requests inserted the same invoice number.

I had to think about this one quite a bit to find alternatives. I researched how to lock a non-existing record and it appears to me that you can’t in PostgreSQL even at ISOLATION LEVEL SERIALIZABLE.

I did find that in READ COMMITTED that “UPSERT” (INSERT … ON CONFLICT) can help solve the problem by blocking until another transaction inserts the record and then not attempt to insert it again.

-- set up a test table
create table t (id text primary key, other text);

This works: Using ON CONFLICT to make sure that the record is only inserted once and is not updated by a second concurrent transaction:

-- run two transactions with these statements (statement for statement)
begin transaction isolation level read committed;
select txid_current();
-- optionally abort if it already exists
select * from t where id = 'tryupsert';
-- optionally if found exit

INSERT INTO t (id, other) VALUES ('tryupsert', txid_current()::text)
ON CONFLICT (id) DO NOTHING;

-- second transaction blocks on the above
select * from t where id = 'tryupsert';
commit;
-- second transaction unblocked now
-- The first transaction inserted the row if it did not exist
-- The second transaction executed the DO NOTHING.

This works: Using ON CONFLICT to make sure that the record exists and the last transaction wins on the other column value (all transactions change the value of the row):

-- run two transactions with these statements (statement for statement)
begin transaction isolation level read committed;
select txid_current();

INSERT INTO t (id, other)
SELECT 'tryupsert' AS id, txid_current()::text AS other
ON CONFLICT (id) DO UPDATE SET (id, other) = (excluded.id, excluded.other)
RETURNING *;

-- second transaction blocks on the above
commit;
-- second transaction unblocked now
-- The first transaction inserted the row if it did not exist
-- or updated it if it did exist.
-- The second transaction updated the row.

This works: Using advisory locks:

-- run two transactions with these statements (statement for statement)
begin transaction isolation level read committed;
select txid_current();
select * from t where id = 'advisory';
-- if found exit

select pg_advisory_xact_lock(hashtext('advisory'));
-- second transaction blocks on the above
select * from t where id = 'advisory';
-- if found exit
-- second transaction will unblock when first tx commits and see the row 
-- was already inserted from the first transaction and skip the insert.

INSERT INTO t (id, other) VALUES ('advisory', txid_current()::text);

select * from t where id = 'advisory';
commit;

Using SERIALIZABLE doesn’t work. This makes sense after understanding better (yet again because I keep forgetting how it works – it is kind of designed for less blocking and code that is built to retry on serialization failure)

begin transaction isolation level serializable;
select * from t where id = 'tryserializable' for update;
-- if found exit

-- second transaction does not block
insert into t values ('tryserializable', txid_current()::text);
-- second transaction block on the above
select * from t where id = 'tryserializable';
-- id | other
-- -----------------+-------
-- tryserializable | 628
commit;

The second transaction ends with this error:

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.