Postgres race condition involving subselect and foreign key

Viewed 674

We have 2 tables defined as follows

CREATE TABLE foo (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL UNIQUE
);

CREATE TABLE bar (
  foo_id BIGINT UNIQUE,
  foo_name TEXT NOT NULL UNIQUE REFERENCES foo (name)
);

I've noticed that when executing the following two queries concurrently

INSERT INTO foo (name) VALUES ('BAZ')
INSERT INTO bar (foo_name, foo_id) VALUES ('BAZ', (SELECT id FROM foo WHERE name = 'BAZ'))

it is possible under certain circumstances to end up inserting a row into bar where foo_id is NULL. The two queries are executed in different transactions, by two completely different processes.

How is this possible? I'd expect the second statement to either fail due to a foreign key violation (if the record in foo is not there), or succeed with a non-null value of foo_id (if it is).

What is causing this race condition? Is it due to the subselect, or is it due to the timing of when the foreign key constraint is checked?

We are using isolation level "read committed" and postgres version 10.3.

EDIT

I think the question was not particularly clear on what is confusing me. The question is about how and why 2 different states of the database were being observed during the execution of a single statement. The subselect is observing that the record in foo as being absent, whereas the fk check sees it as present. If it's just that there's no rule preventing this race condition, then this is an interesting question in itself - why would it not be possible to use transaction ids to ensure that the same state of the database is observed for both?

5 Answers

The subselect in the INSERT INTO bar cannot see the new row concurrently inserted in foo because the latter is not committed yet.

But by the time that the query that checks the foreign key constraint is executed, the INSERT INTO foo has committed, so the foreign key constraint doesn't report an error.

A simple way to work around that is to use the REPEATABLE READ isolation level for the INSERT INT bar. Then the foreign key check uses the same snapshot as the INSERT, it won't see the newly committed row, and a constraint violation error will be thrown.

Logic suggests that ordering of the commands (including the sub-query), combined with when Postgres checks of constraints (which is not necessarily immediate) could cause the issue. Therefore you could

  • Have the second command start first
  • Have the SELECT component run and return NULL
  • First command starts and inserts row
  • Second command inserts the row (with the 'name' field and a NULL)
  • FK reference check is successful as 'name' exists

Re deferrable constraints see https://www.postgresql.org/docs/13/sql-set-constraints.html and https://begriffs.com/posts/2017-08-27-deferrable-sql-constraints.html

Suggested answers

  • Have a not null check on BAR for Foo_Id, or included as part of foreign key checks
  • Rewrite the two commands to run consecutively rather than simultaneously (if possible)

You do indeed have a race condition. Without some sort of locking or use of a transaction to sequence the events, there is no rule precluding the sequence

  1. The sub select of the bar INSERT is performed, yielding NULL
  2. The INSERT into foo
  3. The INSERT into bar, which now does not have any FK violation, but does have a NULL.

Since of course this is the toy version of your real program, I can't recommend how best to fix it. If it makes sense to require these events in a particular sequence, then they can be in a transaction on a single thread. In some other situation, you might prohibit inserting directly into foo and bar (REVOKE permissions as necessary) and allow modifications only through a function/procedure, or through a view that has triggers (possibly rules).

An anonymous plpgsql block will help you avoid the race conditions (by making sure that the inserts run sequentially within the same transaction) without going deep into Postgres internals:

do language plpgsql
$$
declare
 v_foo_id bigint;
begin
 INSERT into foo (name) values ('BAZ') RETURNING id into v_foo_id;
 INSERT into bar (foo_name, foo_id) values ('BAZ', v_foo_id);
end;
$$;

or using plain SQL with a CTE in order to avoid switching context to/from plpgsql:

with t(id) as 
(
 INSERT into foo (name) values ('BAZ') RETURNING id
) 
INSERT into bar (foo_name, foo_id) values ('BAZ', (select id from t));

And, btw, are you sure that the two inserts in your example are executed in the same transaction in the right order? If not then the short answer to your question is "MVCC" since the second statement is not atomic.

This seems more likely a scenario where both queries executed one after another but transaction is not committed.

Process 1

INSERT INTO foo (name) VALUES ('BAZ')

Transaction not committed but Process 2 execute next query

INSERT INTO bar (foo_name, foo_id) VALUES ('BAZ', (SELECT id FROM foo WHERE name = 'BAZ'))

In this case process 2 query will wait until process 1 transaction isn't committed.

From PostgreSQL doc :

UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress).

Related