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.
