Skip to content

b. Holds & Locking

A hold says "these seats are mine for 10 minutes, while I pay". The invariant: a seat belongs to at most one live hold. Everything in this step is about keeping that invariant true when hundreds of fans click the same seats in the same second.

1. Why "check, then update" is not enough

The obvious code reads the seats, checks they are AVAILABLE, then updates them. Under concurrency, two transactions can interleave:

sequenceDiagram
    autonumber
    participant A as Fan A (tx 1)
    participant DB as PostgreSQL
    participant B as Fan B (tx 2)
    A->>DB: SELECT status FROM seats WHERE id IN (7,8)
    DB-->>A: AVAILABLE, AVAILABLE
    B->>DB: SELECT status FROM seats WHERE id IN (7,8)
    DB-->>B: AVAILABLE, AVAILABLE
    A->>DB: UPDATE seats SET status='HELD', hold_id=A ...
    B->>DB: UPDATE seats SET status='HELD', hold_id=B ... (waits for A's row locks)
    A->>DB: COMMIT
    DB-->>B: update proceeds β€” overwrites A's hold
    B->>DB: COMMIT
    Note over A,B: both fans got a 201 for seats 7 and 8: DOUBLE BOOKING

PostgreSQL's default isolation, READ COMMITTED, does not prevent this lost update: each statement sees committed data, but nothing ties B's decision (made on the SELECT) to the data when B writes. Three fixes:

Strategy How On conflict Good when
Pessimistic SELECT … FOR UPDATE locks the rows until commit; the second transaction waits, then sees HELD 409 after waiting contention is high (the flash sale: everybody wants the same 100 seats)
Optimistic read without locks; UPDATE … WHERE version = :v; 0 rows changed β†’ someone was faster 409 immediately, the transaction rolls back contention is low; no waiting, no lock queues
Conditional update UPDATE … WHERE id IN (…) AND status = 'AVAILABLE' and check the row count 409 immediately a single statement can express the check (a variant of optimistic)
Serializable isolation SET TRANSACTION ISOLATION LEVEL SERIALIZABLE the database aborts one transaction (40001), the app retries complex invariants over many rows

2. The inventory service

Create services/inventory (port 8083, schema inventory, same Dockerfile pattern with EXPOSE 8083). It uses JdbcClient with plain SQL instead of JPA: every lock and every WHERE is visible in the code, which is the point of this lab.

services/inventory/Dockerfile and .dockerignore (same as catalog, only EXPOSE 8083 changes)
services/inventory/Dockerfile
# syntax=docker/dockerfile:1

# ---- build stage: compile with Maven inside a JDK image -------------------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
# the cache mount keeps ~/.m2 between builds: dependencies are downloaded once
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests

# ---- runtime stage: only a JRE and the jar --------------------------------
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8083
# use up to 75% of the container's memory limit for the heap
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
services/inventory/.dockerignore
target/
.idea/
.vscode/
*.iml
services/inventory/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>boxoffice</groupId>
    <artifactId>inventory</artifactId>
    <version>1.0.0</version>
    <name>inventory</name>
    <description>BoxOffice inventory: seats and holds, strongly consistent</description>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- plain SQL with JdbcClient: every lock and every WHERE clause is visible -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!-- schema migrations (Boot 4: the starter brings the auto-configuration) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
services/inventory/src/main/resources/application.yaml
server:
  port: 8083

spring:
  application:
    name: inventory
  # migrations always go straight to the primary database (not through PgBouncer)
  flyway:
    url: jdbc:postgresql://${DB_MIGRATION_HOST:localhost}:5432/${DB_NAME:boxoffice}
    user: ${DB_USER:boxoffice}
    password: ${DB_PASSWORD:boxoffice}
    schemas: inventory
  mvc:
    problemdetails:
      enabled: true

inventory:
  locking: ${INVENTORY_LOCKING:pessimistic}     # naive | pessimistic | optimistic
  hold-ttl: ${INVENTORY_HOLD_TTL:PT10M}         # a hold expires after 10 minutes
  max-seats-per-hold: 6
  datasource:
    primary:                                    # writes and read-your-writes reads
      jdbc-url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:boxoffice}
      username: ${DB_USER:boxoffice}
      password: ${DB_PASSWORD:boxoffice}
      maximum-pool-size: ${DB_POOL_SIZE:10}
      pool-name: primary
    replica:                                    # @Transactional(readOnly = true) reads
      jdbc-url: jdbc:postgresql://${DB_REPLICA_HOST:localhost}:${DB_REPLICA_PORT:5432}/${DB_NAME:boxoffice}
      username: ${DB_USER:boxoffice}
      password: ${DB_PASSWORD:boxoffice}
      maximum-pool-size: ${DB_POOL_SIZE:10}
      pool-name: replica
      read-only: true

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics
  endpoint:
    health:
      probes:
        enabled: true
services/inventory/src/main/resources/db/migration/V1__create_inventory.sql
-- Seats of every event, and the holds that reserve them for a few minutes.
-- event_id refers to catalog.events, but there is NO foreign key across services.
CREATE TABLE inventory.seats (
    id           BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    event_id     BIGINT        NOT NULL,
    section      VARCHAR(10)   NOT NULL,
    seat_row     INTEGER       NOT NULL,
    seat_number  INTEGER       NOT NULL,
    price        NUMERIC(10,2) NOT NULL,
    status       VARCHAR(10)   NOT NULL DEFAULT 'AVAILABLE'
                 CHECK (status IN ('AVAILABLE', 'HELD', 'SOLD')),
    hold_id      UUID,
    version      INTEGER       NOT NULL DEFAULT 0,          -- optimistic locking
    UNIQUE (event_id, section, seat_row, seat_number)       -- also the index for "seats of an event"
);

CREATE TABLE inventory.holds (
    id          UUID         PRIMARY KEY,
    event_id    BIGINT       NOT NULL,
    user_id     VARCHAR(64)  NOT NULL,
    status      VARCHAR(10)  NOT NULL CHECK (status IN ('ACTIVE', 'CONFIRMED', 'RELEASED', 'EXPIRED')),
    expires_at  TIMESTAMPTZ  NOT NULL,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

-- which seats each hold asked for: lets us AUDIT double bookings afterwards
CREATE TABLE inventory.hold_seats (
    hold_id  UUID   NOT NULL REFERENCES inventory.holds (id),
    seat_id  BIGINT NOT NULL REFERENCES inventory.seats (id),
    PRIMARY KEY (hold_id, seat_id)
);
services/inventory/src/main/resources/db/migration/V2__seed_seats.sql
-- Event 1 (the flash sale): 30 sections x 40 rows x 50 seats = 60,000 seats.
INSERT INTO inventory.seats (event_id, section, seat_row, seat_number, price)
SELECT 1, 'S' || lpad(s::text, 2, '0'), r, n, 250 + (s % 5) * 100
FROM generate_series(1, 30) AS s, generate_series(1, 40) AS r, generate_series(1, 50) AS n
ORDER BY s, r, n;

-- Events 2..100: 5 sections x 10 rows x 10 seats = 500 seats each.
INSERT INTO inventory.seats (event_id, section, seat_row, seat_number, price)
SELECT e, 'S' || lpad(s::text, 2, '0'), r, n, 80 + s * 20
FROM generate_series(2, 100) AS e, generate_series(1, 5) AS s,
     generate_series(1, 10) AS r, generate_series(1, 10) AS n
ORDER BY e, s, r, n;

ANALYZE inventory.seats;

hold_seats records which seats each hold asked for. The seats.hold_id column can be overwritten by a buggy strategy, but hold_seats keeps the evidence: the audit query counts seats that appear in more than one live hold.

The records and the application class:

services/inventory/src/main/java/boxoffice/inventory/InventoryApplication.java
package boxoffice.inventory;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling          // the job that expires old holds
public class InventoryApplication {

    public static void main(String[] args) {
        SpringApplication.run(InventoryApplication.class, args);
    }
}
services/inventory/src/main/java/boxoffice/inventory/HoldIn.java
package boxoffice.inventory;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;

public record HoldIn(@NotNull Long eventId, @NotEmpty List<@NotNull Long> seatIds) {
}
services/inventory/src/main/java/boxoffice/inventory/HoldOut.java
package boxoffice.inventory;

import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;

public record HoldOut(UUID id, Long eventId, String status, OffsetDateTime expiresAt, List<Long> seatIds) {
}
services/inventory/src/main/java/boxoffice/inventory/SeatOut.java
package boxoffice.inventory;

import java.math.BigDecimal;

public record SeatOut(Long id, String section, int row, int number, BigDecimal price, String status) {
}
services/inventory/src/main/java/boxoffice/inventory/SectionAvailability.java
package boxoffice.inventory;

public record SectionAvailability(String section, long available, long held, long sold) {
}

3. Three strategies in one service

services/inventory/src/main/java/boxoffice/inventory/HoldService.java
package boxoffice.inventory;

import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

/**
 * Holds: "these seats are mine for 10 minutes". The one rule that must never break:
 * a seat belongs to at most ONE live hold. Three ways to (try to) enforce it are
 * selectable with INVENTORY_LOCKING, so you can measure each one.
 */
@Service
public class HoldService {

    private static final Logger log = LoggerFactory.getLogger(HoldService.class);

    private record Seat(long id, String status, int version) { }

    private final JdbcClient jdbc;
    private final String locking;
    private final Duration holdTtl;
    private final int maxSeats;

    public HoldService(JdbcClient jdbc,
                       @Value("${inventory.locking}") String locking,
                       @Value("${inventory.hold-ttl}") Duration holdTtl,
                       @Value("${inventory.max-seats-per-hold}") int maxSeats) {
        this.jdbc = jdbc;
        this.locking = locking;
        this.holdTtl = holdTtl;
        this.maxSeats = maxSeats;
        log.info("seat locking strategy: {}", locking);
    }

    @Transactional        // READ COMMITTED (PostgreSQL's default), on the primary
    public HoldOut hold(String userId, HoldIn in) {
        var seatIds = in.seatIds().stream().distinct().sorted().toList();
        if (seatIds.size() > maxSeats) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "at most " + maxSeats + " seats per hold");
        }
        var holdId = UUID.randomUUID();
        var expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plus(holdTtl);

        switch (locking) {
            case "naive" -> holdNaive(in.eventId(), seatIds, holdId);
            case "optimistic" -> holdOptimistic(in.eventId(), seatIds, holdId);
            default -> holdPessimistic(in.eventId(), seatIds, holdId);
        }

        jdbc.sql("""
                INSERT INTO inventory.holds (id, event_id, user_id, status, expires_at)
                VALUES (:id, :eventId, :userId, 'ACTIVE', :expiresAt)""")
                .param("id", holdId).param("eventId", in.eventId())
                .param("userId", userId).param("expiresAt", expiresAt)
                .update();
        for (var seatId : seatIds) {
            jdbc.sql("INSERT INTO inventory.hold_seats (hold_id, seat_id) VALUES (:holdId, :seatId)")
                    .param("holdId", holdId).param("seatId", seatId)
                    .update();
        }
        return new HoldOut(holdId, in.eventId(), "ACTIVE", expiresAt, seatIds);
    }

    // ---- strategy 1: check-then-act, no lock (WRONG on purpose) ---------------
    private void holdNaive(long eventId, List<Long> seatIds, UUID holdId) {
        var seats = read(eventId, seatIds, "");
        requireAvailable(seats, seatIds);
        // race window: another transaction may have read the same AVAILABLE seats and
        // be about to run this same UPDATE β€” both will "win"
        jdbc.sql("UPDATE inventory.seats SET status = 'HELD', hold_id = :holdId WHERE id IN (:ids)")
                .param("holdId", holdId).param("ids", seatIds)
                .update();
    }

    // ---- strategy 2: pessimistic, SELECT ... FOR UPDATE -----------------------
    private void holdPessimistic(long eventId, List<Long> seatIds, UUID holdId) {
        // row locks until commit; a second transaction on the same seats WAITS here.
        // Locking in id order (ORDER BY id) prevents deadlocks between overlapping holds.
        var seats = read(eventId, seatIds, "ORDER BY id FOR UPDATE");
        requireAvailable(seats, seatIds);
        jdbc.sql("UPDATE inventory.seats SET status = 'HELD', hold_id = :holdId, version = version + 1 WHERE id IN (:ids)")
                .param("holdId", holdId).param("ids", seatIds)
                .update();
    }

    // ---- strategy 3: optimistic, compare-and-set on a version column ---------------
    private void holdOptimistic(long eventId, List<Long> seatIds, UUID holdId) {
        var seats = read(eventId, seatIds, "");                      // no lock
        requireAvailable(seats, seatIds);
        for (var seat : seats) {
            int changed = jdbc.sql("""
                    UPDATE inventory.seats
                    SET status = 'HELD', hold_id = :holdId, version = version + 1
                    WHERE id = :id AND version = :version AND status = 'AVAILABLE'""")
                    .param("holdId", holdId).param("id", seat.id()).param("version", seat.version())
                    .update();
            if (changed == 0) {
                // someone changed this seat after we read it: give up (the exception rolls back)
                throw new ResponseStatusException(HttpStatus.CONFLICT, "seat " + seat.id() + " was just taken");
            }
        }
    }

    private List<Seat> read(long eventId, List<Long> seatIds, String suffix) {
        return jdbc.sql("SELECT id, status, version FROM inventory.seats "
                        + "WHERE event_id = :eventId AND id IN (:ids) " + suffix)
                .param("eventId", eventId).param("ids", seatIds)
                .query((rs, n) -> new Seat(rs.getLong("id"), rs.getString("status"), rs.getInt("version")))
                .list();
    }

    private static void requireAvailable(List<Seat> seats, List<Long> seatIds) {
        if (seats.size() != seatIds.size()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "some seats do not belong to this event");
        }
        if (seats.stream().anyMatch(s -> !"AVAILABLE".equals(s.status()))) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "some seats are not available");
        }
    }

    // ---- reading, confirming and releasing a hold (always on the primary) -----

    @Transactional        // NOT readOnly: read-your-writes, the user just created it
    public HoldOut find(String userId, UUID holdId) {
        var hold = jdbc.sql("""
                SELECT id, event_id, status, expires_at FROM inventory.holds
                WHERE id = :id AND user_id = :userId""")
                .param("id", holdId).param("userId", userId)
                .query((rs, n) -> new HoldOut(rs.getObject("id", UUID.class), rs.getLong("event_id"),
                        rs.getString("status"), rs.getObject("expires_at", OffsetDateTime.class), List.of()))
                .optional()
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "hold not found"));
        var seatIds = jdbc.sql("SELECT seat_id FROM inventory.hold_seats WHERE hold_id = :id ORDER BY seat_id")
                .param("id", holdId).query(Long.class).list();
        return new HoldOut(hold.id(), hold.eventId(), hold.status(), hold.expiresAt(), seatIds);
    }

    /** Payment succeeded (lab 7 calls this): seats become SOLD. */
    @Transactional
    public HoldOut confirm(String userId, UUID holdId) {
        int changed = jdbc.sql("""
                UPDATE inventory.holds SET status = 'CONFIRMED'
                WHERE id = :id AND user_id = :userId AND status = 'ACTIVE' AND expires_at > now()""")
                .param("id", holdId).param("userId", userId)
                .update();
        if (changed == 0) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "hold is not active (expired, released or unknown)");
        }
        jdbc.sql("UPDATE inventory.seats SET status = 'SOLD', version = version + 1 WHERE hold_id = :id")
                .param("id", holdId).update();
        return find(userId, holdId);
    }

    @Transactional
    public void release(String userId, UUID holdId) {
        int changed = jdbc.sql("""
                UPDATE inventory.holds SET status = 'RELEASED'
                WHERE id = :id AND user_id = :userId AND status = 'ACTIVE'""")
                .param("id", holdId).param("userId", userId)
                .update();
        if (changed == 0) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no active hold " + holdId);
        }
        freeSeatsOf(holdId);
    }

    private void freeSeatsOf(UUID holdId) {
        jdbc.sql("""
                UPDATE inventory.seats SET status = 'AVAILABLE', hold_id = NULL, version = version + 1
                WHERE hold_id = :id AND status = 'HELD'""")
                .param("id", holdId).update();
    }

    /**
     * Every 5 s, expire old holds and free their seats. FOR UPDATE SKIP LOCKED lets several
     * inventory replicas run this job at the same time without processing the same hold twice.
     */
    @Scheduled(fixedDelay = 5000)
    @Transactional
    public void expireHolds() {
        int freed = jdbc.sql("""
                WITH expired AS (
                    UPDATE inventory.holds SET status = 'EXPIRED'
                    WHERE id IN (SELECT id FROM inventory.holds
                                 WHERE status = 'ACTIVE' AND expires_at < now()
                                 ORDER BY expires_at
                                 LIMIT 500
                                 FOR UPDATE SKIP LOCKED)
                    RETURNING id)
                UPDATE inventory.seats
                SET status = 'AVAILABLE', hold_id = NULL, version = version + 1
                WHERE status = 'HELD' AND hold_id IN (SELECT id FROM expired)""")
                .update();
        if (freed > 0) {
            log.info("expired holds freed {} seats", freed);
        }
    }
}

Read it strategy by strategy:

  • naive: read(…, "") then an unconditional UPDATE. The comment marks the race window.
  • pessimistic: read(…, "ORDER BY id FOR UPDATE"). The second transaction blocks on the row locks; when the first commits, PostgreSQL re-reads the locked rows, the second sees HELD, and requireAvailable throws 409. Locking in a fixed order (ORDER BY id) matters: fan A holding seats 7β†’8 and fan B holding 8β†’7 in opposite order would deadlock.
  • optimistic: no locks; the UPDATE only succeeds if version is still the one we read. If another transaction changed the seat in between, 0 rows change, we throw, and @Transactional rolls back everything (including seats already updated in the loop).
  • Every hold also checks that all seats belong to the event (400 otherwise) and limits a hold to 6 seats.
  • find, confirm and release filter by user_id: a fan cannot see or cancel somebody else's hold (that would be an IDOR, insecure direct object reference β€” OWASP API #1).
  • expireHolds runs every 5 s on every replica; FOR UPDATE SKIP LOCKED lets several replicas share the work without blocking each other or processing a hold twice β€” the same trick used by job queues built on PostgreSQL.

The read-only queries live in a separate class, because they go to the replica (step 2):

services/inventory/src/main/java/boxoffice/inventory/SeatQueries.java
package boxoffice.inventory;

import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/** Read-only queries: they run on the REPLICA (readOnly = true), so they may be slightly stale. */
@Service
public class SeatQueries {

    private final JdbcClient jdbc;

    public SeatQueries(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional(readOnly = true)
    public List<SectionAvailability> availability(long eventId) {
        return jdbc.sql("""
                SELECT section,
                       count(*) FILTER (WHERE status = 'AVAILABLE') AS available,
                       count(*) FILTER (WHERE status = 'HELD')      AS held,
                       count(*) FILTER (WHERE status = 'SOLD')      AS sold
                FROM inventory.seats
                WHERE event_id = :eventId
                GROUP BY section
                ORDER BY section""")
                .param("eventId", eventId)
                .query((rs, n) -> new SectionAvailability(rs.getString("section"),
                        rs.getLong("available"), rs.getLong("held"), rs.getLong("sold")))
                .list();
    }

    @Transactional(readOnly = true)
    public List<SeatOut> seats(long eventId, String section) {
        return jdbc.sql("""
                SELECT id, section, seat_row, seat_number, price, status
                FROM inventory.seats
                WHERE event_id = :eventId AND section = :section
                ORDER BY seat_row, seat_number""")
                .param("eventId", eventId)
                .param("section", section)
                .query((rs, n) -> new SeatOut(rs.getLong("id"), rs.getString("section"),
                        rs.getInt("seat_row"), rs.getInt("seat_number"),
                        rs.getBigDecimal("price"), rs.getString("status")))
                .list();
    }

    /** Seats that belong to more than one live hold: must always be 0. Reads the PRIMARY. */
    @Transactional
    public long doubleBookedSeats() {
        return jdbc.sql("""
                SELECT count(*) FROM (
                    SELECT hs.seat_id
                    FROM inventory.hold_seats hs
                    JOIN inventory.holds h ON h.id = hs.hold_id
                    WHERE h.status IN ('ACTIVE', 'CONFIRMED')
                    GROUP BY hs.seat_id
                    HAVING count(*) > 1) AS doubles""")
                .query(Long.class)
                .single();
    }
}
services/inventory/src/main/java/boxoffice/inventory/InventoryController.java
package boxoffice.inventory;

import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/inventory")
public class InventoryController {

    private final SeatQueries queries;
    private final HoldService holds;

    public InventoryController(SeatQueries queries, HoldService holds) {
        this.queries = queries;
        this.holds = holds;
    }

    // ---- open (GET, through the gateway without a token), served by the replica ----

    @GetMapping("/events/{eventId}/availability")
    public List<SectionAvailability> availability(@PathVariable long eventId) {
        return queries.availability(eventId);
    }

    @GetMapping("/events/{eventId}/seats")
    public List<SeatOut> seats(@PathVariable long eventId, @RequestParam String section) {
        return queries.seats(eventId, section);
    }

    // ---- protected: the gateway sets X-User-Id from the JWT ----

    @PostMapping("/holds")
    public ResponseEntity<HoldOut> hold(@RequestHeader("X-User-Id") String userId, @Valid @RequestBody HoldIn in) {
        var out = holds.hold(userId, in);
        return ResponseEntity.created(URI.create("/inventory/holds/" + out.id())).body(out);
    }

    @GetMapping("/holds/{id}")
    public HoldOut find(@RequestHeader("X-User-Id") String userId, @PathVariable UUID id) {
        return holds.find(userId, id);
    }

    @PostMapping("/holds/{id}/confirm")
    public HoldOut confirm(@RequestHeader("X-User-Id") String userId, @PathVariable UUID id) {
        return holds.confirm(userId, id);
    }

    @DeleteMapping("/holds/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void release(@RequestHeader("X-User-Id") String userId, @PathVariable UUID id) {
        holds.release(userId, id);
    }

    /** The invariant check used after a flash-sale test: must be {"doubleBookedSeats": 0}. */
    @GetMapping("/audit/double-bookings")
    public Map<String, Long> audit() {
        return Map.of("doubleBookedSeats", queries.doubleBookedSeats());
    }
}

4. Wire it into the gateway

Add the route and open the read endpoints (holds stay protected by the default rule):

services/gateway/src/main/resources/application.yaml
server:
  port: 8080

spring:
  application:
    name: gateway
  cloud:
    gateway:
      server:
        webflux:
          httpclient:
            connect-timeout: 1000          # ms to open a connection to a service
            response-timeout: 5s           # never wait forever for a service
          default-filters:
            # retry idempotent GETs once on another instance when a connection fails
            - name: Retry
              args:
                retries: 1
                methods: GET
                series: SERVER_ERROR
                exceptions: java.io.IOException, java.util.concurrent.TimeoutException
          routes:
            - id: auth
              uri: ${AUTH_URL:http://auth:8081}
              predicates:
                - Path=/auth/**
            - id: catalog
              uri: lb://catalog              # "lb": pick one instance of "catalog" per request
              predicates:
                - Path=/catalog/**
            - id: inventory
              uri: ${INVENTORY_URL:http://inventory:8083}
              predicates:
                - Path=/inventory/**
    # the instances behind lb://catalog (a static list; Kubernetes replaces it in lab 10)
    discovery:
      client:
        simple:
          instances:
            catalog:
              - uri: http://catalog-1:8082
              - uri: http://catalog-2:8082
              - uri: http://catalog-3:8082
    loadbalancer:
      configurations: health-check         # skip instances that are not ready
      health-check:
        interval: 5s
        path:
          default: /actuator/health/readiness   # this instance, not db/redis that every replica shares
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: ${AUTH_JWKS_URI:http://auth:8081/auth/.well-known/jwks.json}

jwt:
  issuer: ${JWT_ISSUER:boxoffice-auth}
  audience: boxoffice

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, gateway
  endpoint:
    health:
      probes:
        enabled: true
    gateway:
      access: read-only      # GET /actuator/gateway/routes lists the routes
services/gateway/src/main/java/boxoffice/gateway/SecurityConfig.java
package boxoffice.gateway;

import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    /** Which routes are open, which need a valid token, which need the admin scope. */
    @Bean
    SecurityWebFilterChain security(ServerHttpSecurity http) {
        return http
                .csrf(ServerHttpSecurity.CsrfSpec::disable)        // stateless API, no cookies
                .authorizeExchange(ex -> ex
                        // open: getting a token, and the public keys to check one
                        .pathMatchers("/auth/register", "/auth/login", "/auth/.well-known/**").permitAll()
                        // open: browsing the catalog (HEAD is a GET without the body)
                        .pathMatchers(HttpMethod.GET, "/catalog/**").permitAll()
                        .pathMatchers(HttpMethod.HEAD, "/catalog/**").permitAll()
                        // admin only: changing the catalog (scope claim "user admin")
                        .pathMatchers("/catalog/**").hasAuthority("SCOPE_admin")
                        // open: seat maps and availability (lab 6); holds need a token
                        .pathMatchers(HttpMethod.GET, "/inventory/events/**").permitAll()
                        .pathMatchers(HttpMethod.HEAD, "/inventory/events/**").permitAll()
                        // health, metrics, routes: the edge never forwards /actuator from outside
                        .pathMatchers("/actuator/**").permitAll()
                        // everything else (and every future route): any valid token
                        .anyExchange().authenticated())
                .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
                .build();
    }

    /** Signature from the JWKS of auth, plus expiry, issuer and audience checks. */
    @Bean
    ReactiveJwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwksUri,
                                  @Value("${jwt.issuer}") String issuer,
                                  @Value("${jwt.audience}") String audience) {
        var decoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwksUri).build();
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
                JwtValidators.createDefaultWithIssuer(issuer),     // exp, nbf, iss
                new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(audience))));
        return decoder;
    }
}

The Compose file with inventory, the replica and PgBouncer is in step 2; bring everything up after that step, or now with the full file.

5. Check it by hand

With the full stack of step 2 running, log in and walk through a hold. All commands from the repository root:

curl -s -X POST localhost/auth/register -H 'Content-Type: application/json' \
  -d '{"name":"Ana","email":"ana@example.com","password":"secret-123"}'; echo
export TOKEN=$(curl -s -X POST localhost/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"ana@example.com","password":"secret-123"}' | jq -r .accessToken)
export ADMIN=$(curl -s -X POST localhost/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"admin@boxoffice.dev","password":"boxoffice-admin"}' | jq -r .accessToken)

curl -s localhost/inventory/events/1/availability | jq -c '.[0]'
HOLD=$(curl -s -X POST localhost/inventory/holds -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"eventId":1,"seatIds":[1,2]}')
echo "$HOLD"
export HOLD_ID=$(echo "$HOLD" | jq -r .id)

# the same seat for somebody else, no token, somebody else's hold
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost/inventory/holds -H "Authorization: Bearer $ADMIN" \
  -H 'Content-Type: application/json' -d '{"eventId":1,"seatIds":[2,3]}'
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost/inventory/holds \
  -H 'Content-Type: application/json' -d '{"eventId":1,"seatIds":[5]}'
curl -s -o /dev/null -w "%{http_code}\n" localhost/inventory/holds/$HOLD_ID -H "Authorization: Bearer $ADMIN"

# pay (lab 7 will do this through order) and look again
curl -s -X POST localhost/inventory/holds/$HOLD_ID/confirm -H "Authorization: Bearer $TOKEN" | jq -c '{status, seatIds}'
curl -s localhost/inventory/events/1/availability | jq -c '.[0]'
curl -s localhost/inventory/audit/double-bookings -H "Authorization: Bearer $TOKEN"

You should see (from a run for this lab; ids and times differ):

{"id":"0b9d8a6e-…","name":"Ana","email":"ana@example.com","role":"USER"}
{"section":"S01","available":2000,"held":0,"sold":0}
{"id":"c869f791-701d-41dd-b82f-d5a6ba48093e","eventId":1,"status":"ACTIVE","expiresAt":"2026-10-18T17:51:50.567788219Z","seatIds":[1,2]}
409
401
404
{"status":"CONFIRMED","seatIds":[1,2]}
{"section":"S01","available":1998,"held":0,"sold":2}
{"doubleBookedSeats":0}

409: seat 2 is already held. 401: holds need a token. 404: a hold of another user does not exist for you. Run bash scripts/reset-inventory.sh afterwards to put the seats back on sale.

6. The flash-sale test

load/lib.js
// Shared helpers for the lab 6 scripts: create fans and log them in.
import http from 'k6/http';
import { check } from 'k6';

export const BASE_URL = __ENV.BASE_URL || 'http://localhost';
const json = { headers: { 'Content-Type': 'application/json' } };

/** Registers `count` fans and returns one JWT per fan (runs in setup(); BCrypt makes it take a few seconds). */
export function loginFans(count) {
  const run = Date.now();
  const tokens = [];
  for (let i = 0; i < count; i++) {
    const email = `fan-${run}-${i}@boxoffice.dev`;
    http.post(`${BASE_URL}/auth/register`, JSON.stringify({ name: `Fan ${i}`, email, password: 'secret-123' }), json);
    const res = http.post(`${BASE_URL}/auth/login`, JSON.stringify({ email, password: 'secret-123' }), json);
    check(res, { 'login is 200': (r) => r.status === 200 });
    tokens.push(res.json('accessToken'));
  }
  return tokens;
}

export function authHeaders(token) {
  return { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } };
}
load/flash-sale.js
// Lab 6: the 10:00 flash sale. 200 fans fight for the first 100 seats of section S01
// of event 1. Each fan tries to hold 2 seats; some change their mind and release.
// Run it once per locking strategy (INVENTORY_LOCKING) and compare.
//   bash scripts/reset-inventory.sh && k6 run load/flash-sale.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter } from 'k6/metrics';
import { BASE_URL, loginFans, authHeaders } from './lib.js';

// a 409 (seat taken) is an expected answer in a flash sale, not a failure
http.setResponseCallback(http.expectedStatuses(200, 201, 204, 409));

const held = new Counter('holds_created');
const conflicts = new Counter('holds_conflict');

export const options = {
  scenarios: {
    fans: { executor: 'constant-vus', vus: Number(__ENV.VUS || 200), duration: __ENV.DURATION || '30s' },
  },
  summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
  thresholds: {
    'http_req_duration{name:hold}': ['p(95)<1000'],
    http_req_failed: ['rate<0.01'],
    holds_created: ['count>0'],
    holds_conflict: ['count>=0'],
  },
};

export function setup() {
  const tokens = loginFans(50);
  const seats = http.get(`${BASE_URL}/inventory/events/1/seats?section=S01`).json();
  return { tokens, seatIds: seats.slice(0, 100).map((s) => s.id) };   // rows 1-2: the best seats
}

export default function (data) {
  const token = data.tokens[__VU % data.tokens.length];
  const i = Math.floor(Math.random() * (data.seatIds.length - 1));
  const body = JSON.stringify({ eventId: 1, seatIds: [data.seatIds[i], data.seatIds[i + 1]] });

  const res = http.post(`${BASE_URL}/inventory/holds`, body, { ...authHeaders(token), tags: { name: 'hold' } });
  check(res, { 'hold answered 201 or 409': (r) => r.status === 201 || r.status === 409 });
  if (res.status === 201) {
    held.add(1);
    sleep(0.5);
    if (Math.random() < 0.7) {                       // 70% give up: the seats go back on sale
      http.del(`${BASE_URL}/inventory/holds/${res.json('id')}`, null, { ...authHeaders(token), tags: { name: 'release' } });
    }
  } else if (res.status === 409) {
    conflicts.add(1);
    sleep(0.2);
  }
}

// after the test: the invariant. Anything other than 0 is a double booking.
export function teardown(data) {
  const audit = http.get(`${BASE_URL}/inventory/audit/double-bookings`, authHeaders(data.tokens[0]));
  console.log(`AUDIT double-booked seats: ${audit.json('doubleBookedSeats')}`);
}
scripts/reset-inventory.sh
#!/usr/bin/env bash
# Lab 6: put every seat back on sale and forget all holds (run before each flash-sale test).
set -euo pipefail
docker compose exec -T db psql -q -U "${DB_USER:-boxoffice}" -d "${DB_NAME:-boxoffice}" -v ON_ERROR_STOP=1 <<'SQL'
BEGIN;
DELETE FROM inventory.hold_seats;
DELETE FROM inventory.holds;
UPDATE inventory.seats SET status = 'AVAILABLE', hold_id = NULL, version = 0
WHERE status <> 'AVAILABLE' OR hold_id IS NOT NULL;
COMMIT;
SQL
echo "inventory reset: all seats AVAILABLE"

Run it once per strategy, resetting the seats each time:

mkdir -p results
for mode in naive pessimistic optimistic; do
  INVENTORY_LOCKING=$mode docker compose up -d inventory
  sleep 25                                   # wait until inventory is healthy again
  bash scripts/reset-inventory.sh
  k6 run load/flash-sale.js 2>&1 | tee results/lab6-flash-$mode.txt | grep -E "AUDIT|holds_.*:|\{ name:hold"
done
INVENTORY_LOCKING=naive ... k6 run load/flash-sale.jstime="…" level=info msg="AUDIT double-booked seats: 9" source=console
holds_conflict.................: 11582 263.28/s
holds_created..................: 162 3.68/s
{ name:hold }................: avg=312.03ms med=208.6ms p(95)=808.96ms p(99)=1.88s
INVENTORY_LOCKING=pessimistic ... k6 run load/flash-sale.jstime="…" level=info msg="AUDIT double-booked seats: 0" source=console
holds_conflict.................: 16184 402.17/s
holds_created..................: 141 3.50/s
{ name:hold }................: avg=171.94ms med=78.37ms p(95)=665.17ms p(99)=1.77s
INVENTORY_LOCKING=optimistic ... k6 run load/flash-sale.jstime="…" level=info msg="AUDIT double-booked seats: 0" source=console
holds_conflict.................: 20028 512.57/s
holds_created..................: 141 3.61/s
{ name:hold }................: avg=93.62ms med=31.02ms p(95)=354.94ms p(99)=1.03s

These numbers come from one run on a busy laptop β€” yours will differ β€” but the shape is what matters: naive created more holds than the seats allow and the audit found 9 double-booked seats; pessimistic and optimistic always end with 0. With only 100 seats, most attempts are 409 (the seats are gone), which the script counts as an expected answer, not an error. Compare also the latency of the hold requests: pessimistic transactions queue on row locks, optimistic ones fail fast β€” here optimistic had the lowest p99, because a conflicting fan learns "taken" without waiting for anybody's lock.

Think first: if pessimistic locking is correct, why would anyone use optimistic locking?

Locks cost waiting. When contention is low (fans spread over 60,000 seats, most holds touch different rows), optimistic locking never waits and almost never conflicts, so it is cheaper. When contention is high (everybody wants the first rows), optimistic locking turns into a storm of conflicts and retries, while pessimistic locking serializes the requests neatly. Measure your contention before choosing β€” and remember that the real flash-sale design also reduces contention before the database: a waiting room (lab 7) admits fans at a rate the inventory can absorb.

In the interview

For "design Ticketmaster", say explicitly: "seat inventory is the one place that needs strong consistency; I will use a relational database with row-level locking (or conditional updates) for holds, with a TTL on each hold, and everything else β€” catalog, search, seat maps β€” can be eventually consistent and cached." Then mention the hot-row problem: thousands of fans on the same 100 seats serialize on their locks, so queue them before the database.

Next: 2. Replica and pooling.