DBMS

Practice And Capstone

Capstone: Designing a Real System

Events have several ticket types, each with

JrCodex·11 min read

Jr Codex DBMS Notes

Level: Advanced Prerequisites: All previous modules Time to complete: ~30 minutes reading; the build is a multi-session project


Table of Contents

  1. The Brief
  2. The ER Model
  3. The Schema
  4. The Queries
  5. The Indexes
  6. Concurrency and Correctness
  7. Operations
  8. Extensions
  9. Summary & Next Steps

1. The Brief

The System
─────────────────────────────────────────
  A CONFERENCE TICKETING PLATFORM.

  Events have several ticket types, each with
  limited capacity. Attendees buy tickets. Ticket
  types may sell out.

  Non-negotiable rules:
    - never sell more tickets than capacity
    - never charge without issuing a ticket, or
      vice versa
    - the price paid is fixed at purchase, even if
      the listed price changes later
    - organisers see live sales figures
─────────────────────────────────────────
Why This Task
─────────────────────────────────────────
  Every rule maps to a module:

    capacity limit   ──► concurrency (Module 7)
    charge + ticket  ──► atomicity (Module 1)
    price paid       ──► historical snapshot
                         (Module 4, Chapter 5)
    live figures     ──► indexing and aggregation
                         (Modules 3, 5)

  And "never oversell" is a multi-row invariant —
  which Module 7, Chapter 2 showed is exactly where
  weak isolation fails.
─────────────────────────────────────────

2. The ER Model

Entities and Relationships
─────────────────────────────────────────
  ORGANISER ──1:N──► EVENT ──1:N──► TICKET_TYPE
                                         │
                                        1:N
                                         ▼
  ATTENDEE ──1:N──► ORDER ──1:N──► ORDER_ITEM

  ORDER_ITEM is a WEAK ENTITY — "item 2" means
  nothing without its order (Module 4, Chapter 1).

  TICKET is issued per seat sold, so a party of
  three produces one order_item of quantity 3 and
  three tickets — because each attendee needs their
  own scannable code.
─────────────────────────────────────────
The Modelling Decision Worth Noting
─────────────────────────────────────────
  Should `sold_count` live on ticket_types, or be
  computed from order_items?

  COMPUTED  always correct, but every capacity
            check aggregates — and the check is on
            the hot path of every purchase.

  STORED    fast, and it is redundant data
            requiring a synchronisation mechanism
            and a drift check (Module 4, Chapter 5).

  We store it, because the capacity CHECK constraint
  needs a single row to evaluate against — which
  turns the invariant into something the database
  enforces rather than the application.
─────────────────────────────────────────

3. The Schema

CREATE TABLE organisers (
    id    BIGSERIAL PRIMARY KEY,
    name  TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE
);
 
CREATE TABLE events (
    id           BIGSERIAL PRIMARY KEY,
    organiser_id BIGINT NOT NULL REFERENCES organisers(id) ON DELETE RESTRICT,
    title        TEXT NOT NULL,
    venue        TEXT NOT NULL,
    starts_at    TIMESTAMPTZ NOT NULL,
    status       TEXT NOT NULL DEFAULT 'draft'
                 CHECK (status IN ('draft','published','cancelled')),
    CHECK (starts_at > created_at)
);
 
CREATE TABLE ticket_types (
    id          BIGSERIAL PRIMARY KEY,
    event_id    BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
    name        TEXT NOT NULL,
    price       NUMERIC(10,2) NOT NULL CHECK (price >= 0),   -- NUMERIC, never REAL
    capacity    INTEGER NOT NULL CHECK (capacity > 0),
    sold_count  INTEGER NOT NULL DEFAULT 0 CHECK (sold_count >= 0),
    sales_open  TIMESTAMPTZ NOT NULL,
    sales_close TIMESTAMPTZ NOT NULL,
 
    UNIQUE (event_id, name),
    CHECK (sold_count <= capacity),          -- ← THE INVARIANT, enforced by the database
    CHECK (sales_close > sales_open)
);
 
CREATE TABLE attendees (
    id    BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    name  TEXT NOT NULL
);
 
CREATE TABLE orders (
    id           BIGSERIAL PRIMARY KEY,
    attendee_id  BIGINT NOT NULL REFERENCES attendees(id) ON DELETE RESTRICT,
    event_id     BIGINT NOT NULL REFERENCES events(id)    ON DELETE RESTRICT,
    status       TEXT NOT NULL DEFAULT 'pending'
                 CHECK (status IN ('pending','paid','cancelled','refunded')),
    total        NUMERIC(10,2) NOT NULL CHECK (total >= 0),
    placed_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    paid_at      TIMESTAMPTZ,
    idempotency_key TEXT UNIQUE,             -- retry-safe (Module 7, Chapter 5)
    CHECK (paid_at IS NULL OR paid_at >= placed_at),
    CHECK ((status = 'paid') = (paid_at IS NOT NULL))      -- states stay consistent
);
 
CREATE TABLE order_items (
    order_id       BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    line_no        INTEGER NOT NULL,
    ticket_type_id BIGINT NOT NULL REFERENCES ticket_types(id) ON DELETE RESTRICT,
    quantity       INTEGER NOT NULL CHECK (quantity > 0),
    unit_price     NUMERIC(10,2) NOT NULL,   -- the price PAID — not a copy of the
    PRIMARY KEY (order_id, line_no)          -- current price (Module 4, Chapter 5)
);
 
CREATE TABLE tickets (
    id             BIGSERIAL PRIMARY KEY,
    order_id       BIGINT NOT NULL,
    line_no        INTEGER NOT NULL,
    ticket_type_id BIGINT NOT NULL REFERENCES ticket_types(id),
    code           TEXT NOT NULL UNIQUE,
    checked_in_at  TIMESTAMPTZ,
    FOREIGN KEY (order_id, line_no) REFERENCES order_items(order_id, line_no)
                 ON DELETE CASCADE
);
Read the CHECK Constraints Again
─────────────────────────────────────────
  CHECK (sold_count <= capacity)

  This one line makes overselling IMPOSSIBLE — not
  discouraged, not usually prevented. No code path,
  no manual fix, no race condition can produce a
  row violating it, because the database refuses
  the write (Module 1, Chapter 4's consistency).

  Section 6 shows what the application must do
  about that refusal. But the guarantee lives here.
─────────────────────────────────────────

4. The Queries

-- Q1. Live sales dashboard for an organiser. Runs constantly.
SELECT e.title,
       tt.name,
       tt.capacity,
       tt.sold_count,
       tt.capacity - tt.sold_count             AS remaining,
       ROUND(100.0 * tt.sold_count / tt.capacity, 1) AS pct_sold,
       tt.sold_count * tt.price                AS gross
FROM events e
JOIN ticket_types tt ON tt.event_id = e.id
WHERE e.organiser_id = $1 AND e.status = 'published'
ORDER BY e.starts_at, tt.price DESC;
-- Q2. An attendee's order history. Note the aggregate-then-join (Module 3, Chapter 5).
SELECT o.id, o.placed_at, o.status, o.total, e.title, e.starts_at,
       x.ticket_count
FROM orders o
JOIN events e ON e.id = o.event_id
LEFT JOIN (
    SELECT oi.order_id, SUM(oi.quantity) AS ticket_count
    FROM order_items oi GROUP BY oi.order_id
) x ON x.order_id = o.id
WHERE o.attendee_id = $1
ORDER BY o.placed_at DESC
LIMIT 20;
-- Q3. Revenue by event and month, from the price PAID.
SELECT e.title,
       date_trunc('month', o.paid_at) AS month,
       COUNT(DISTINCT o.id)           AS orders,
       SUM(oi.quantity)               AS tickets,
       SUM(oi.quantity * oi.unit_price) AS revenue    -- unit_price, NOT tt.price
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN events e       ON e.id = o.event_id
WHERE o.status = 'paid' AND o.paid_at >= $1 AND o.paid_at < $2   -- SARGABLE half-open
GROUP BY e.title, date_trunc('month', o.paid_at)
ORDER BY month DESC, revenue DESC;
-- Q4. Events at risk: published, starting soon, under 50% sold.
SELECT e.title, e.starts_at,
       SUM(tt.capacity) AS capacity, SUM(tt.sold_count) AS sold
FROM events e
JOIN ticket_types tt ON tt.event_id = e.id
WHERE e.status = 'published' AND e.starts_at BETWEEN now() AND now() + INTERVAL '14 days'
GROUP BY e.id, e.title, e.starts_at
HAVING SUM(tt.sold_count) < 0.5 * SUM(tt.capacity)      -- HAVING: filters GROUPS
ORDER BY e.starts_at;

5. The Indexes

-- Foreign keys — not automatic in PostgreSQL (Module 5, Chapter 4).
CREATE INDEX idx_events_organiser     ON events (organiser_id);
CREATE INDEX idx_ticket_types_event   ON ticket_types (event_id);
CREATE INDEX idx_orders_attendee      ON orders (attendee_id);
CREATE INDEX idx_orders_event         ON orders (event_id);
CREATE INDEX idx_order_items_type     ON order_items (ticket_type_id);
CREATE INDEX idx_tickets_type         ON tickets (ticket_type_id);
 
-- Q1: composite, equality column first (Module 5, Chapter 3).
CREATE INDEX idx_events_org_published ON events (organiser_id, starts_at)
    WHERE status = 'published';                          -- PARTIAL: skewed status
 
-- Q2: matches both the filter and the ORDER BY, so no sort step.
CREATE INDEX idx_orders_attendee_time ON orders (attendee_id, placed_at DESC);
 
-- Q3: partial + covering. Only paid orders are ever reported on.
CREATE INDEX idx_orders_paid ON orders (paid_at) INCLUDE (event_id, total)
    WHERE status = 'paid';
 
-- Q4: upcoming published events only — a small fraction of the table.
CREATE INDEX idx_events_upcoming ON events (starts_at)
    WHERE status = 'published';
 
-- Ticket scanning at the door: an exact-match lookup, must be instant.
CREATE UNIQUE INDEX idx_tickets_code ON tickets (code);
Why So Many Are Partial
─────────────────────────────────────────
  `status = 'published'` and `status = 'paid'`
  are heavily skewed and appear in almost every
  query.

  A partial index on them is a fraction of the size
  of a full one, stays entirely in the buffer pool,
  and is untouched by writes to rows outside the
  predicate (Module 5, Chapter 3).

  Skewed status columns are the single best
  partial-index opportunity in most schemas.
─────────────────────────────────────────

6. Concurrency and Correctness

The heart of the system: two people buying the last ticket simultaneously.

from psycopg.errors import SerializationFailure, CheckViolation
import time, random
 
PURCHASE = """
WITH claimed AS (
    UPDATE ticket_types
    SET    sold_count = sold_count + %(qty)s          -- ATOMIC read-modify-write
    WHERE  id = %(type_id)s
      AND  sold_count + %(qty)s <= capacity           -- the guard, in the statement
      AND  now() BETWEEN sales_open AND sales_close
    RETURNING id, price
)
INSERT INTO orders (attendee_id, event_id, status, total, idempotency_key)
SELECT %(attendee)s, %(event)s, 'pending', c.price * %(qty)s, %(idem)s
FROM claimed c
RETURNING id;
"""
 
def purchase(conn, attendee, event, type_id, qty, idem_key, attempts=3):
    for attempt in range(attempts):
        try:
            with conn.transaction():
                row = conn.execute(PURCHASE, {
                    "attendee": attendee, "event": event, "type_id": type_id,
                    "qty": qty, "idem": idem_key}).fetchone()
 
                if row is None:                 # the UPDATE matched no row
                    return None                 # ── sold out, or sales closed
                order_id = row[0]
 
                conn.execute("""INSERT INTO order_items
                                (order_id, line_no, ticket_type_id, quantity, unit_price)
                                SELECT %s, 1, id, %s, price
                                FROM ticket_types WHERE id = %s""",
                             (order_id, qty, type_id))     -- unit_price SNAPSHOT
                return order_id
 
        except SerializationFailure:            # Module 7, Chapter 2 — retries required
            if attempt == attempts - 1:
                raise
            time.sleep(0.01 * 2 ** attempt + random.random() * 0.01)
        except CheckViolation:                  # the CHECK caught it — genuinely sold out
            return None
Why the Guard Is Inside the UPDATE
─────────────────────────────────────────
  The naive version:

    SELECT sold_count FROM ticket_types WHERE id=?
    if sold_count + qty <= capacity:
        UPDATE ... SET sold_count = sold_count + qty

  Two buyers both read 99 of 100, both decide there
  is room, both write 100 — and one seat is
  oversold. This is the LOST UPDATE from Module 7,
  Chapter 2.

  Putting the condition in the UPDATE's WHERE makes
  the check and the write ONE atomic statement. The
  database serialises them, and the second buyer's
  UPDATE matches zero rows.

  The CHECK constraint then remains as a
  belt-and-braces guarantee — if any future code
  path forgets the guard, the database still
  refuses.
─────────────────────────────────────────
The Layers of Defence, Counted
─────────────────────────────────────────
  1. the guard in the UPDATE's WHERE  ── prevents
                                         the race
  2. CHECK (sold_count <= capacity)   ── makes the
                                         bad state
                                         unrepresentable
  3. idempotency_key UNIQUE           ── a retried
                                         request
                                         cannot
                                         double-buy
  4. one transaction                  ── order and
                                         items
                                         together,
                                         or neither

  Any one of these could be forgotten in a future
  change. All four failing silently is very
  unlikely.
─────────────────────────────────────────

7. Operations

-- The DRIFT CHECK for the denormalised sold_count (Module 4, Chapter 5).
-- Run nightly. Should always return zero rows.
SELECT tt.id, tt.name, tt.sold_count AS stored, COALESCE(SUM(oi.quantity), 0) AS actual
FROM ticket_types tt
LEFT JOIN order_items oi ON oi.ticket_type_id = tt.id
LEFT JOIN orders o       ON o.id = oi.order_id AND o.status IN ('paid','pending')
GROUP BY tt.id, tt.name, tt.sold_count
HAVING tt.sold_count <> COALESCE(SUM(oi.quantity), 0);
The Operational Checklist
─────────────────────────────────────────
  □ WAL archiving on, PITR tested by RESTORING
    (Module 8, Chapter 3)
  □ synchronous_commit = on — this is money
  □ A read replica for the organiser dashboard, so
    reporting never competes with sales
  □ pg_stat_statements enabled, reviewed weekly
  □ The drift check scheduled and alerting
  □ Partition `tickets` by event once it grows
  □ Monitor lock waits during on-sale spikes
─────────────────────────────────────────

8. Extensions

In Rough Order of Difficulty
─────────────────────────────────────────
  1. Seat reservations that EXPIRE after 10
     minutes — a pending hold, released by a
     scheduled job.

  2. Refunds, keeping the audit trail intact and
     correctly decrementing sold_count.

  3. Discount codes with usage limits — another
     multi-row invariant, same pattern.

  4. Waitlists, notifying in order when seats free
     up (SKIP LOCKED, Module 7, Chapter 3).

  5. Assigned seating — a seats table, and a
     genuinely harder concurrency problem.

  6. Sharding by event_id for a very large
     platform, and everything Module 9 says that
     costs.
─────────────────────────────────────────

9. Summary & Next Steps

Key Takeaways

  • Business invariants belong in the schema: CHECK (sold_count <= capacity) makes overselling unrepresentable rather than merely unlikely.
  • Putting the capacity guard inside the UPDATE's WHERE makes check-and-write one atomic statement, eliminating the lost-update race entirely.
  • unit_price on the order item is a historical snapshot, not redundancy — the price paid and the current price are different facts.
  • Partial indexes on skewed status columns serve most of this system's queries at a fraction of a full index's size.

Module 10 Capstone Complete

You have designed a system where the correctness rules are enforced by the database, the hot queries have indexes chosen from the plans, the concurrency has four independent layers of defence, and the redundant value has a scheduled drift check. That is the whole curriculum in one schema.

Concept Check

  1. Explain the lost-update race in the naive purchase flow, and why moving the condition into the UPDATE eliminates it.
  2. Why is unit_price stored on order_items rather than joined from ticket_types?
  3. Which four independent mechanisms prevent overselling, and why is having all four worthwhile?

Next Chapter

Chapter 3: Where to Go Next


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to DBMS Index