Matheus de Paula

One Seat Experiment

#database_locking#concurrency#postgres

One Seat Experiment

This article is part of a series of "experiments" that I run against a lab project, Event Forge — a fake ticket-selling platform I use to study and test concepts.

The main idea

Ticketing platforms get uneven traffic — it swings with the demand of specific events. What I want to check is concurrent behaviour on events where tickets are scarce and demand is high.

The same seat, requested N or more times.

A basic understanding of the data model

The three tables this experiment touches: allocations, holds and hold_lines.

Event Forge has other tables besides the ones that show up here: organizers, venues, seat maps, price tiers. None of that matters for this particular experiment. Only three tables interest me for this study — and the fight to write to one of them in particular.

The allocations table, and the disputed row

allocations is the table I care about. It is where I can state which seats are available at the moment a new event is published — the table takes a kind of snapshot when an event changes state (draft -> published, for example).

An important distinction about the idea of selling tickets for events: General Admission (GA) vs. seated.

General admission lets us sell a ticket without assigning a specific seat. For an event in a stadium with a GA capacity of 500 people, it does not matter where you sit. A seated event is different — there the specific seat (the chair itself, I might say) is what you are buying. That difference is not only about the ticket. It is what the table looks like:

  • Seated — one row per seat, capacity = 1. A section with 500 seats becomes 500 rows. Two buyers only meet when they ask for the same seat.
  • GA — one single row, capacity = 500, working as a counter. Every buyer writes to the same row, so all of them queue on it.

So one table gives me two very different fights: many cold rows, or one hot row. The experiment needs both, and that is why my test data has both.

The other two tables

holds is the claim itself — who is holding, for which event, and until when (expires_at, 15 minutes). hold_lines says what the claim is made of: one line per allocation, with a quantity. One for a seat, N for a slice of a GA counter.

Worth saying out loud: expiry is written but nothing enforces it yet. An expired hold keeps its units off sale. That is a different experiment.

Placing a hold

There is one endpoint, POST /events/{eventId}/holds, and it does four things in this order:

  1. Check that the event is on sale. Nothing else accepts holds.
  2. Open a transaction and lock the allocation rows the request named.
  3. Judge the locked rows: are there enough units left?
  4. If yes, move the units and write the hold and its lines.

Everything interesting happens because step 2 comes before step 3.

The question

N attendees request the same seat, at the same instant. Does exactly one get it?

The important part is that "yes" has to be provable. A browser cannot show me a race — I would have to click faster than the network. A test can: fire many requests at the same seat at once and count the winners.

So the test fires 16 simultaneous claims for one seat, and repeats that 50 times, each round against a different seat. For every round it asserts:

  • exactly 1 response is 201;
  • the other 15 are 409 with the code ALLOCATION_UNAVAILABLE;
  • the seat row ends with held = 1;
  • exactly one hold_line points at that seat.

There is a second race for the GA counter, with capacity = 5: 16 claims, 5 winners, 11 losers, and the row ends at exactly 5.

How exactly one wins

The lock is one line of SQL:

sql
SELECT "id", "event_id", "capacity", "held", "reserved"
FROM "allocations"
WHERE "id" = ANY($1::uuid[])
ORDER BY "id"
FOR UPDATE

FOR UPDATE takes the row. A second transaction naming the same seat stops right there and waits.

The winner is not the one who writes first — it is the one who arrives first. The loser never writes at all:

A, the winner B, the loser
t0 locks the row, reads held = 0 —
t1 — tries to lock, blocks
t2 UPDATE … SET held = 1 still waiting
t3 COMMIT, lock released wakes up
t4 — re-reads the row, sees held = 1, gives up

t4 is the whole mechanism. B does not decide from what it read when it arrived; it decides from what A committed. It then returns insufficient_units, which the service throws, which rolls its transaction back — leaving no trace.

ORDER BY "id" is there for a reason too. If one request locks seat A then seat B, and another locks B then A, each is holding what the other needs and Postgres kills one with a deadlock error. From outside that looks exactly like losing a seat. Sorting the ids means every request in the system locks in the same order, so it cannot happen.

The safety net

Under all of it there is a CHECK constraint on the table:

sql
CONSTRAINT "allocations_no_oversell_check" CHECK ("held" + "reserved" <= "capacity")

This is the actual authority, not the lock. If everything I wrote above is wrong, Postgres still refuses the write and do not let me oversell

I checked that on purpose. Removing FOR UPDATE makes the test fail — and it fails with losers failing for the wrong reason, not with two winners.

I have to pay a price

Losers wait, and they wait exactly as long as the winner's transaction lasts. So whatever I put inside that transaction is paid for by everyone else in the queue, and each of them is holding a database connection while waiting. That is why the response is read back after the commit, not inside it.

For the detailed reasoning behind each decision above, see takeaways.

← all writing