noddde

Seat Reservation — Pessimistic Concurrency

Serializing concurrent seat reservations with pessimistic locking, MySQL advisory locks via Prisma, and lock timeout handling.

A concert venue where customers reserve specific seats. Multiple customers attempt to reserve the same seat concurrently. This sample demonstrates pessimistic concurrency with advisory locks — the right strategy when command handlers involve expensive validation and wasted retries are costly.

Full source: samples/sample-seat-reservation — clone and run locally with npm start.

Stack: Prisma + MySQL + Testcontainers

Why Pessimistic

The reservation handler is expensive: it checks seat availability, validates customer eligibility, applies adjacency rules, and computes pricing. With optimistic concurrency, two customers reserving the same seat would both run this full validation pipeline, only for one to fail at save time and retry — wasting all that computation.

With pessimistic locking, the second customer waits briefly for the lock, then runs validation against the current state. No wasted work.

The Aggregate

export const Venue = defineAggregate<VenueTypes>({
  initialState: { seats: {} },
  commands: {
    CreateVenue: (command) => ({
      name: "VenueCreated",
      payload: {
        venueId: command.targetAggregateId,
        seatIds: command.payload.seatIds,
      },
    }),
    ReserveSeat: (command, state, infrastructure) => {
      const { seatId, customerId } = command.payload;
      const seat = state.seats[seatId];

      if (!seat) {
        return {
          name: "ReservationRejected",
          payload: { seatId, customerId, reason: "seat_not_found" },
        };
      }
      if (seat.status !== "available") {
        return {
          name: "ReservationRejected",
          payload: { seatId, customerId, reason: `seat_${seat.status}` },
        };
      }

      // Expensive validation: adjacency rules, pricing, eligibility...
      const _now = infrastructure.clock.now();

      return { name: "SeatReserved", payload: { seatId, customerId } };
    },
    ReleaseSeat: (command) => ({
      name: "SeatReleased",
      payload: { seatId: command.payload.seatId },
    }),
  },
  apply: {
    VenueCreated: (payload) => {
      const seats: Record<string, SeatInfo> = {};
      for (const id of payload.seatIds) seats[id] = { status: "available" };
      return { seats };
    },
    SeatReserved: (payload, state) => ({
      seats: {
        ...state.seats,
        [payload.seatId]: { status: "reserved", heldBy: payload.customerId },
      },
    }),
    SeatReleased: (payload, state) => ({
      seats: { ...state.seats, [payload.seatId]: { status: "available" } },
    }),
    ReservationRejected: (_payload, state) => state,
  },
});

The ReserveSeat handler uses infrastructure.clock — a real-world handler would call external services for pricing and eligibility.

Domain Configuration

const locker = new PrismaAdvisoryLocker(prisma, "mysql");

const domain = await configureDomain<VenueInfrastructure>({
  writeModel: { aggregates: { Venue } },
  readModel: { projections: {} },
  infrastructure: {
    aggregatePersistence: () => prismaInfra.eventSourcedPersistence,
    unitOfWorkFactory: () => prismaInfra.unitOfWorkFactory,
    aggregateConcurrency: {
      strategy: "pessimistic",
      locker,
      lockTimeoutMs: 5000,
    },
    provideInfrastructure: () => ({ clock: new SystemClock() }),
  },
});

PrismaAdvisoryLocker uses MySQL's GET_LOCK('Venue:concert-hall', 5) to serialize access. The lock key is derived from the aggregate name and ID. lockTimeoutMs: 5000 means if a customer can't acquire the lock within 5 seconds, a LockTimeoutError is thrown instead of waiting indefinitely.

What Happens at Runtime

1. Create venue with seats A1, A2, A3
2. 3 customers fire ReserveSeat for A1 concurrently (Promise.all)
3. Customer 1 acquires GET_LOCK('Venue:concert-hall', 5) → runs handler → SeatReserved
4. Customer 2 acquires lock (Customer 1 released it) → runs handler → seat is now reserved → ReservationRejected
5. Customer 3 acquires lock → same → ReservationRejected
6. Result: 1 reserved, 2 rejected — no retries, no wasted validation

Each customer runs the handler exactly once. The lock ensures they see the correct state — no stale reads.

Running the Sample

cd samples/sample-seat-reservation && yarn start

Requires Docker running (Testcontainers spins up MySQL automatically).

Key Patterns Demonstrated

  • Pessimistic concurrency with PrismaAdvisoryLocker — serialized access via MySQL GET_LOCK
  • Lock timeoutlockTimeoutMs: 5000 prevents indefinite blocking; throws LockTimeoutError
  • Infrastructure injectionClock passed to command handlers for time-dependent validation
  • Rejection eventsReservationRejected records why a reservation failed (seat not found, already taken)
  • Testcontainers — real MySQL database, no external setup
  • Prisma adaptercreatePrismaPersistence + PrismaAdvisoryLocker

On this page