Table of Contents
Sometimes you want to enforce a condition on a table that cannot be implemented by a constraint. In such a case it is tempting to use triggers instead. This article describes how to do this and what to watch out for.
It will also familiarize you with the little-known PostgreSQL feature of “constraint triggers”.
Suppose we have a table of prisons and a table of prison guards:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
CREATE SCHEMA jail_app; CREATE TABLE jail_app.prison ( prison_id integer PRIMARY KEY, prison_name text NOT NULL ); INSERT INTO jail_app.prison (prison_id, prison_name) VALUES (1, 'Karlau'), (2, 'Stein'); CREATE TABLE jail_app.guard ( guard_id integer PRIMARY KEY, guard_name text NOT NULL ); INSERT INTO jail_app.guard (guard_id, guard_name) VALUES (41, 'Alice'), (42, 'Bob'), (43, 'Chris'); |
Then we have a junction table that stores which guard is on duty in which prison:
1 2 3 4 5 6 7 8 |
CREATE TABLE jail_app.on_duty ( prison_id integer REFERENCES prison, guard_id integer REFERENCES guard, PRIMARY KEY (prison_id, guard_id) ); INSERT INTO jail_app.on_duty (prison_id, guard_id) VALUES (1, 41), (2, 42), (2, 43); |
So, Alice is on duty in Karlau, and Bob and Chris are on duty in Stein.
As guards go on and off duty, rows are added to and deleted from on_duty
. We want to establish a constraint that at least one guard has to be on duty in any given prison.
Unfortunately there is no way to write this as a normal database constraint (if you are tempted to write a CHECK
constraint that counts the rows in the table, think again).
But it would be easy to write a BEFORE DELETE
trigger that ensures the condition:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
CREATE FUNCTION jail_app.checkout_trig() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN IF (SELECT count(*) FROM jail_app.on_duty WHERE prison_id = OLD.prison_id ) < 2 THEN RAISE EXCEPTION 'sorry, you are the only guard on duty'; END IF; RETURN OLD; END;$$; CREATE TRIGGER checkout_trig BEFORE DELETE ON jail_app.on_duty FOR EACH ROW EXECUTE PROCEDURE jail_app.checkout_trig(); |
But, as we will see in the next section, we made a crucial mistake here.
Imagine Bob wants to go off duty.
The prison guard application runs a transaction like the following:
1 2 3 4 5 6 7 8 |
START TRANSACTION; DELETE FROM jail_app.on_duty WHERE guard_id = (SELECT guard_id FROM jail_app.guard WHERE guard_name = 'Bob'); COMMIT; |
Now if Chris happens to have the same idea at the same time, the following could happen (the highlighted lines form a second, concurrent transaction):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
START TRANSACTION; DELETE FROM jail_app.on_duty WHERE guard_id = (SELECT guard_id FROM jail_app.guard WHERE guard_name = 'Bob'); START TRANSACTION; DELETE FROM jail_app.on_duty WHERE guard_id = (SELECT guard_id FROM jail_app.guard WHERE guard_name = 'Chris'); COMMIT; COMMIT; |
Now the first transaction has not yet committed when the second UPDATE
runs, so the trigger function running in the second transaction cannot see the effects of the first update. That means that the second transaction succeeds, both guards go off duty, and the prisoners can escape.
You may think that this is a rare occurrence and you can get by ignoring that race condition in your application. But don't forget there are bad people out there, and they may attack your application using exactly such a race condition (in the recent fad of picking impressive names for security flaws, this has been called an ACIDRain attack).
Given the above, you may wonder if regular constraints are subject to the same problem. After all, this is a consequence of PostgreSQL's multi-version concurrency control (MVCC).
When checking constraints, PostgreSQL also checks rows that would normally not be visible to the current transaction. This is against the normal MVCC rules but guarantees that constraints are not vulnerable to this race condition.
You could potentially do the same if you write a trigger function in C, but few people are ready to do that. With trigger functions written in any other language, you have no way to “peek” at uncommitted data.
We can avoid the race condition by explicitly locking the rows we check. This effectively serializes data modifications, so it reduces concurrency and hence performance.
Don't consider locking the whole table, even if it seems a simpler solution.
Our trigger now becomes a little more complicated. We want to avoid deadlocks, so we will make sure that we always lock rows in the same order. For this we need a statement level trigger with a transition table (new since v10):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
CREATE OR REPLACE FUNCTION jail_app.checkout_trig() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN IF EXISTS ( WITH remaining AS ( /* of the prisons where somebody went off duty, select those which have a guard left */ SELECT on_duty.prison_id FROM jail_app.on_duty JOIN deleted ON on_duty.prison_id = deleted.prison_id ORDER BY on_duty.prison_id, on_duty.guard_id /* lock those remaining entries */ FOR KEY SHARE OF on_duty ) SELECT prison_id FROM deleted EXCEPT SELECT prison_id FROM remaining ) THEN RAISE EXCEPTION 'cannot leave a prison without guards'; END IF; RETURN NULL; END;$$; DROP TRIGGER IF EXISTS checkout_trig ON jail_app.on_duty; CREATE TRIGGER checkout_trig AFTER DELETE ON jail_app.on_duty REFERENCING OLD TABLE AS deleted FOR EACH STATEMENT EXECUTE PROCEDURE jail_app.checkout_trig(); |
This technique is called “pessimistic locking” since it expects that there will be concurrent transactions that “disturb” our processing. Such concurrent transactions are preemptively blocked. Pessimistic locking is a good strategy if conflicts are likely.
Different from pessimistic locking, “optimistic locking” does not actually lock the contended objects. Rather, it checks that no concurrent transaction has modified the data between the time we read them and the time we modify the database.
This improves concurrency, and we don't have to change our original trigger definition. The downside is that we must be ready to repeat a transaction that failed because of concurrent data modifications.
The most convenient way to implement optimistic locking is to raise the transaction isolation level. In our case, REPEATABLE READ
is not enough to prevent inconsistencies, and we'll have to use SERIALIZABLE
.
All transactions that access jail_app.on_duty
must start like this:
1 |
START TRANSACTION ISOLATION LEVEL SERIALIZABLE; |
Then PostgreSQL will make sure that concurrent transactions won't succeed unless they are serializable. That means that the transactions can be ordered so that serial execution of the transactions in this order would produce the same result.
If PostgreSQL cannot guarantee this, it will terminate one of the transactions with
1 2 3 |
ERROR: could not serialize access due to read/write dependencies among transactions HINT: The transaction might succeed if retried. |
This is a serialization error (SQLSTATE 40001) and doesn't mean that you did something wrong. Such errors are normal with isolation levels above READ COMMITTED
and tell you to simply retry the transaction.
Optimistic locking is a good strategy if conflicts are expected to occur only rarely. Then you don't have to pay the price of repeating the transaction too often.
It should be noted that SERIALIZABLE
comes with a certain performance hit. This is because PostgreSQL has to maintain additional “predicate locks”. See the documentation for performance considerations.
Finally, PostgreSQL has the option to create “constraint triggers” with CREATE CONSTRAINT TRIGGER
. It sounds like such triggers could be used to avoid the race condition.
Constraint triggers respect the MVCC rules, so they cannot “peek” at uncommitted rows of concurrent transactions. But the trigger execution can be deferred to the end of the transaction. They also have an entry in the pg_constraint
system catalog.
Note that constraint triggers have to be AFTER
triggers FOR EACH ROW
, so we will have to rewrite the trigger function a little:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
CREATE OR REPLACE FUNCTION jail_app.checkout_trig() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN -- the deleted row is already gone in an AFTER trigger IF (SELECT count(*) FROM jail_app.on_duty WHERE prison_id = OLD.prison_id ) < 1 THEN RAISE EXCEPTION 'sorry, you are the only guard on duty'; END IF; RETURN OLD; END;$$; DROP TRIGGER IF EXISTS checkout_trig ON jail_app.on_duty; CREATE CONSTRAINT TRIGGER checkout_trig AFTER DELETE ON jail_app.on_duty DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE PROCEDURE jail_app.checkout_trig(); |
By making the trigger INITIALLY DEFERRED
, we tell PostgreSQL to check the condition at COMMIT
time. This will reduce the window for the race condition a little, but the problem is still there. If concurrent transactions run the trigger function at the same time, they won't see each other's modifications.
If constraint triggers don't live up to the promise in their name, why do they have that name? The answer is in the history of PostgreSQL: CREATE CONSTRAINT TRIGGER
was originally used “under the hood” to create database constraints. Even though that is no more the case, the name has stuck. “Deferrable trigger” would be a better description.
If you don't want to be vulnerable to race conditions with a trigger that enforces a constraint, use locking or higher isolation levels.
Constraint triggers are not a solution.
In order to receive regular updates on important changes in PostgreSQL, subscribe to our newsletter, or follow us on Twitter, Facebook, or LinkedIn.
You need to load content from reCAPTCHA to submit the form. Please note that doing so will share data with third-party providers.
More InformationYou are currently viewing a placeholder content from Facebook. To access the actual content, click the button below. Please note that doing so will share data with third-party providers.
More InformationYou are currently viewing a placeholder content from X. To access the actual content, click the button below. Please note that doing so will share data with third-party providers.
More Information
Very well written article and very good explanation for regular software developers (not DBA) like me.
Thank you very much!
As you've demonstrated here, we still need ASSERTIONs.
You say CONSTANT TRIGGER should really be seen/named as “Deferrable trigger”
Surely must be some difference as those two are independent of each keywords there.
Can create CONSTANT TRIGGER that is NOT DEFERRABLE and regular TRIGGER that is DEFERRABLE, correct? Would the last one still "reduce the window for the race condition" in the same way as CONSTANT DEFERRABLE from the post?
From the documentation:
So it is really the same.
The reason that they are named “constraint triggers” is that deferrable constraints are implemented with such triggers.
In
jail_app.checkout_trig()
, you lock the relevant rows withfor update
. Would it be enough, to lock them withfor key share
instead? If I get you right, you want to prevent the rows from being deleted. If that is true,for key share
would do the job. However, probably you want to prevent (no key) updates, too. Otherwise, somebody could tamper with prison_id and remove guards in this way. Then, you could still usefor share
, right?Yes, that is true, and it is always good to take the minimal required lock. I will change the code.