Dealing with Race Conditions: A Practical Example
Tags: concurrency, database, go, javascript
Published: 2024-07-13 23:24:11

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](https://github.com/iamseki/dev-to/tree/main/apps/hospital-shifts)

## 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:

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

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:

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

```

#### Reproducing the problem

The application is a simple API implemented in Golang. [Checkout the GitHub repository](https://github.com/iamseki/dev-to/tree/main/apps/hospital-shifts) 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:

```sh
     ✓ 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
```

> You'll need tools such as Yarn, Go, K6 and Docker, or you can use [DevBox](https://github.com/iamseki/dev-to/tree/main?tab=readme-ov-file#environment-setup-wrench) 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](https://www.postgresql.org/docs/16/transaction-iso.html) on transaction isolation. Additionally, [here is](https://dl.acm.org/doi/abs/10.1145/1620585.1620587) 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:
   ```golang
   	// Init transaction with serializable isolation level
   	tx, err := db.BeginTxx(c.Request().Context(),    &sql.TxOptions{
   		Isolation: sql.LevelSerializable,
   	})
   ```

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

```sql
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;
```

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:

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

- You can find the complete example in the function [updateWithSerializableIsolation](https://github.com/iamseki/dev-to/blob/main/apps/hospital-shifts/handlers.go#L64-L92).


### 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](https://www.postgresql.org/docs/16/explicit-locking.html#ADVISORY-LOCKS).

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](https://www.postgresql.org/docs/16/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS). In our case, we'll use `pg_try_advisory_xact_lock(key bigint) → boolean`, which automatically releases the lock after a commit or rollback:

```sql
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;
```

Here is the complete function used in our application:

```sql
-- 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;
```

- You can find the complete example in the function [updateWithAdvisoryLock](https://github.com/iamseki/dev-to/blob/main/apps/hospital-shifts/handlers.go#L48C6-L76).

## 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 https://github.com/iamseki/dev-to no-readme %}