Handling Race Conditions in an Event Ticketing System with Oban

I’m currently building an event ticketing system in Elixir and I’m looking for advice on properly handling race conditions around ticket inventory.

I’m using Oban for background processing with jobs responsible for:

  • Initiating payments

  • Delivering tickets after successful payment

  • Restoring tickets when payment fails or expires

The main inventory field being updated is rem_quantity.

I also have a cron job that periodically checks for pending orders older than 5 minutes and marks them as expired.

My concern is around concurrent updates to ticket inventory. For example:

  • Multiple users attempting to reserve the same ticket simultaneously

  • A payment succeeding while the expiration cron job is running

  • Ticket restoration jobs conflicting with payment confirmation jobs

At the moment, I’m worried this could lead to inconsistent rem_quantity values or overselling.

I’d appreciate guidance on best practices for handling this in Elixir/Postgres. Specifically:

  • Should I use database row locking (FOR UPDATE)?

  • Is optimistic locking enough for this use case?

  • What’s the recommended approach for inventory reservation in ticketing systems?

  • How should Oban jobs be structured to remain idempotent and avoid duplicate restoration/delivery operations?

Would also appreciate examples or patterns from production systems if anyone has implemented something similar.

Yes. Transactional guarantees are not enough for this usecase.

I’d suggest not having a rem_quantity in the first place. Instead create actual ticket records in the number of tickets you’ll want to sell. Update those. Number of sold or available tickets are an aggregation on those records. If that turns out a to hot a query for your system you can still cache their values on the read side e.g. with a materialized view, but do not use that on the write side / when checking for available tickets. All the actions like reserving, paying, freeing for tickets should happen in the context of a concrete ticket record.

If you have Oban involved or not at best doesn’t matter at all - at least to the database level modeling.

In general I agree with @LostKobrakai. I suggest you think about the process of securing a ticket, it would be claimed and then possibly purchased or the cart is abandoned and the ticket is returned to the pool. Each of these moments require you to consider an individual ticket instance. Some ticketing situations being oversold is a definite problem, assigned seating for example. You likely want to use optimistic locking as you move tickets through various states, but you need to expect to have to retry on occasion or error.