DEV Community

Christian Seki
Christian Seki

Posted on

Dealing with Race Conditions: A Practical Example

In your career, you'll encounter Schrödinger's cat problems, situations that sometimes work and sometimes don't. Race conditions are one of these challenges (yes, just one!).

Throughout this blog post, I'll present a real-world example, demonstrate how to reproduce the problem and discuss strategies for handling race conditions using serializable transaction isolation and advisory locks with PostgreSQL.

Inspired by "Designing Data-Intensive Applications," Chapter 7 - Transactions "Weak Isolation Levels"

Github Repository with Practical Example

The application

This application manages on-call shifts for doctors at a hospital. To focus on the race condition problem, let's simplify our scenario. Our app resolves around this single table:



CREATE TABLE shifts (
    id SERIAL PRIMARY KEY,
    doctor_name TEXT NOT NULL,
    shift_id INTEGER NOT NULL,
    on_call BOOLEAN NOT NULL DEFAULT FALSE
);


Enter fullscreen mode Exit fullscreen mode

We have a critical business rule:

  • Each shift must always have at least one doctor on call.

As you may have guessed, implementing a naive API can lead to race condition scenarios. Consider this hypothetical situation:

Jack and John are both on-call at the hospital during the same shift. At nearly the same time, they decide to request leave. One succeeds, but the other relies on outdated information about how many doctors are on shift. As a result, both end up leaving their shift, breaking the business rule and leaving a specific shift with no doctors on call:



John --BEGIN------doctors on call: 2-------leave on call-----COMMIT--------> (t)
          \                 \                      \             \
           \                 \                      \             \ 
Database ------------------------------------------------------------------> (t)
               /               /                      /              /
              /               /                      /              /
Jack ------BEGIN------doctors on call: 2-----leave on call----COMMIT-------> (t)



Enter fullscreen mode Exit fullscreen mode

Reproducing the problem

The application is a simple API implemented in Golang. Checkout the GitHub repository for instructions on how to run and execute the script to reproduce this race condition scenario. In summary, you'll need to:

  1. Start the server: yarn nx serve hospital-shifts
  2. Run the k6 test to reproduce the race condition scenario: yarn nx test hospital-shifts

The test attempts to call off two doctors simultaneously, hitting endpoints with different approaches: shiftId=1 uses advisory lock, shiftId=2 uses serializable transaction isolation, and shiftId=3 is a naive implementation without concurrency control.

The k6 results will output custom metrics to indicate which shiftId violated the business rule:



     ✓ at least one doctor on call for shiftId=1
     ✓ at least one doctor on call for shiftId=2
     ✗ at least one doctor on call for shiftId=3
      ↳  36% — ✓ 123 / ✗ 217


Enter fullscreen mode Exit fullscreen mode

You'll need tools such as Yarn, Go, K6 and Docker, or you can use DevBox for an easier setup of repository dependencies.

Addressing the Race Condition

The problem occurs when our application makes a decision based on stale data. This can happen if two transactions run almost simultaneously and both try to call off doctors for their shift. One transaction succeeds as expected, but the other, relying on outdated information, also succeeds incorrectly. How can we prevent this undesired behavior? There are a few ways to achieve this, and I'll explore two options backed by PostgreSQL, though similar solutions can be found in other database management systems.

Serializable Transaction Isolation

Serializable Snapshot Isolation automatically detects and prevents anomalies such as the write skew demonstrated by our application.

I won't dive deep into the theory behind transaction isolation, but it is a common topic in many popular database management systems. You can find good materials by searching for snapshot isolation, like this one from the PostgreSQL official documentation on transaction isolation. Additionally, here is the paper that proposed this solution years ago. Talk is cheap, so let's see the code:

First, start the transaction and set the isolation level to Serializable:



    // Init transaction with serializable isolation level
    tx, err := db.BeginTxx(c.Request().Context(),    &sql.TxOptions{
        Isolation: sql.LevelSerializable,
    })


Enter fullscreen mode Exit fullscreen mode

Then, proceed to execute operations. In our case its execute this function:



CREATE OR REPLACE FUNCTION update_on_call_status_with_serializable_isolation(shift_id_to_update INT, doctor_name_to_update TEXT, on_call_to_update BOOLEAN)
RETURNS VOID AS $$
DECLARE
    on_call_count INT;
BEGIN
    -- Check the current number of doctors on call for this shift
    SELECT COUNT(*) INTO on_call_count FROM shifts s WHERE s.shift_id = shift_id_to_update AND s.on_call = TRUE;

    IF on_call_to_update = FALSE AND on_call_count = 1 THEN
        RAISE EXCEPTION '[SerializableIsolation] Cannot set on_call to FALSE. At least one doctor must be on call for this shiftId: %', shift_id_to_update;
    ELSE
        UPDATE shifts s
        SET on_call = on_call_to_update
        WHERE s.shift_id = shift_id_to_update AND s.doctor_name = doctor_name_to_update;
    END IF;

END;
$$ LANGUAGE plpgsql;


Enter fullscreen mode Exit fullscreen mode

Whenever inconsistent scenarios occur due to concurrent execution, the serializable isolation level will allow one transaction to succeed and will automatically rollback the others with this message, so you can safely retry:



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


Enter fullscreen mode Exit fullscreen mode

Advisory Lock

Another way to ensure our business rules are enforced is by explicitly locking the resource for a specific shift. We can achieve this using an Advisory Lock at the transaction level. This type of lock is fully controlled by the application. You can find more information about it here.

It's crucial to note that locks can be applied at both the session and transaction levels. You can explore the various functions available here. In our case, we'll use pg_try_advisory_xact_lock(key bigint) → boolean, which automatically releases the lock after a commit or rollback:



BEGIN;

-- Attempt to acquire advisory lock and handle failure with EXCEPTION
    IF NOT pg_try_advisory_xact_lock(shift_id_to_update) THEN
        RAISE EXCEPTION '[AdvisoryLock] Could not acquire advisory lock for shift_id: %', shift_id_to_update;
    END IF;

-- Perform necessary operations

-- Commit will automatically release the lock
COMMIT;


Enter fullscreen mode Exit fullscreen mode

Here is the complete function used in our application:



-- Function to Manage On Call Status with Advisory Locks, automatic release when the trx commits
CREATE OR REPLACE FUNCTION update_on_call_status_with_advisory_lock(shift_id_to_update INT, doctor_name_to_update TEXT, on_call_to_update BOOLEAN)
RETURNS VOID AS $$
DECLARE
    on_call_count INT;
BEGIN
    -- Attempt to acquire advisory lock and handle failure with NOTICE
    IF NOT pg_try_advisory_xact_lock(shift_id_to_update) THEN
        RAISE EXCEPTION '[AdvisoryLock] Could not acquire advisory lock for shift_id: %', shift_id_to_update;
    END IF;

    -- Check the current number of doctors on call for this shift
    SELECT COUNT(*) INTO on_call_count FROM shifts s WHERE s.shift_id = shift_id_to_update AND s.on_call = TRUE;

    IF on_call_to_update = FALSE AND on_call_count = 1 THEN
        RAISE EXCEPTION '[AdvisoryLock] Cannot set on_call to FALSE. At least one doctor must be on call for this shiftId: %', shift_id_to_update;
    ELSE
        UPDATE shifts s
        SET on_call = on_call_to_update
        WHERE s.shift_id = shift_id_to_update AND s.doctor_name = doctor_name_to_update;
    END IF;
END;
$$ LANGUAGE plpgsql;


Enter fullscreen mode Exit fullscreen mode

Conclusion

Dealing with race conditions, like the write skew scenario we talked about, can be pretty tricky. There's a ton of research and different ways to solve these problems, so definitely check out some papers and articles if you're curious.

These issues can pop up in real-life situations, like when multiple people try to book the same seat at an event or buy the same spot in a theater. They tend to appear randomly and can be hard to figure out, especially if it's your first time dealing with them.

When you run into race conditions, it's important to look into what solution works best for your specific situation. I might do a benchmark in the future to compare different approaches and give you more insights.

I hope this post has been helpful. Remember, there are tools out there to help with these problems, and you're not alone in facing them!


GitHub logo iamseki / dev-to

Implementations of dev.to blog posts

Top comments (3)

Collapse
 
itsjjpowell profile image
Jonathan Powell

This was a great read! We need more people writing about databases + application code.

I often see folks talk about whether business logic should live in application or database and I think this strikes that balance. Especially in a situation where you could have multiple API instances processing data.

Definitely saving this one for future reference!

Collapse
 
chseki profile image
Christian Seki

Thanks for reading the post! You're totally right, it's a great topic that we deal with in real life and it can lead to some awesome discussions!

Collapse
 
james_rosen_909c36dfbf4a5 profile image
James Rosen • Edited

Would SELECT FOR UPDATE also address this particular case? I'm thinking you could take your Serializable Transaction function and add FOR UPDATE or even FOR SHARE (since other reads wouldn't cause problems) to the SELECT... INTO.

I'd rather bake that isolation into the function than require that the caller know to use Isolation: sql.LevelSerializable.

Assuming this does work as I expect, are there other downsides I'm not seeing?