SwadhyAI Notes

All Low-Level Design notes

Concurrency · LLD

Safely changing object state

A concurrent state transition must move from one valid status to another as one atomic step. Example: a ticket is ACTIVE. Two cashiers tap Pay at the same time. Both see ACTIVE. Both mark PAID. Both open the till. The car leaves once, but the money is collected twice.

The state machine on paper said ACTIVE → PAID. The code did a read, then a write, and two cashiers fit in the gap. The is the critical section, not the enum field.

The real question

Is the transition one step, or a read plus a write?

Machine on paper

Legal arrows, one winner per arrow

CREATED → ACTIVE → PAID → CLOSED

ACTIVE → PAID    only one cashier may win
PAID   → CLOSED  only after money moved
CLOSED → ACTIVE  illegal even on a retry
FREE   → OCCUPIED  same story as spot 101

Illegal transitions still matter when two threads arrive. CLOSED → ACTIVE is wrong on one cashier. ACTIVE → PAID twice is wrong with two. Design that refuse both.

Two cashiers, one arrow

  1. T0Ticket T-1 is ACTIVE. Till closed. Car still in 101.
  2. T1Cashier A reads ACTIVE. Decides to collect.
  3. T2Cashier B reads ACTIVE. Same decision.
  4. T3A writes PAID, opens till, takes twenty rupees.
  5. T4B writes PAID, opens till, takes twenty rupees. Car left once.

The board of ticket status looks PAID at the end. The lost update is the extra debit. Same shape as two tickets for spot 101. Here the shared fact is the status arrow.

The arrow is the critical section

Make the transition the operation. tryPay() succeeds only from ACTIVE to PAID. tryOccupy() succeeds only from FREE to OCCUPIED. The object returns boolean, and the loser does not pretend it won. is a good first Java sentence after that story, not before it.

java
class Ticket {
  private final AtomicReference<TicketStatus> status =
      new AtomicReference<>(TicketStatus.ACTIVE);

  boolean tryPay() {
    return status.compareAndSet(TicketStatus.ACTIVE, TicketStatus.PAID);
  }

  boolean tryClose() {
    return status.compareAndSet(TicketStatus.PAID, TicketStatus.CLOSED);
  }
}

// Cashier: if (!ticket.tryPay()) reject("already paid");

Do not expose setStatus

Any caller can write CLOSED → ACTIVE. A retry webhook can resurrect a closed ticket. Commands like pay() and close() can refuse. A setter cannot. If the interviewer asks for “a status field,” give them an enum that only moves through methods.

java
void setStatus(TicketStatus next) { this.status = next; }  // anyone may cheat

boolean tryPay() {
  return status.compareAndSet(ACTIVE, PAID);               // one atomic step
}

Keep the rest of the work out of the arrow

Do not send SMS inside tryPay while you hold a lock. Claim the , then notify. If the notify fails, you have a paid ticket and a retryable — which is a better mess than two payments. on an already PAID ticket returns false; the till stays shut.

Inside the arrow

Expected state, next state, maybe a version bump. Nothing that calls the network, the printer, or another lock.

After the arrow wins

Open the till, enqueue SMS, print. If those fail, retry the side effect, not the pay. The ticket is already PAID.

Pessimistic twin

If several fields must move with the status — paidAt, cashierId, remaining balance — a lock around the whole if-then is honest. Still treat it as one command, not a setter.

java
synchronized (ticket) {
  if (ticket.status() != TicketStatus.ACTIVE) return false;
  ticket.markPaid(cashierId, Clock.systemUTC().instant());
  return true;
}
// then, outside: till.open(); bus.enqueue(new PaidEvent(ticket.id()));

Movie hold, then book

Seats often have two arrows: FREE → HELD, HELD → BOOKED. Status, holder, and expiry must move together. CAS-ing FREE → HELD and then writing the holder in a second field leaves a window where another thread sees HELD with no owner — and may book or release a hold it does not own. Put the whole seat in one immutable value and replace that value atomically.

java
record SeatState(SeatStatus status, HolderId owner, Instant expiresAt) {}

private final AtomicReference<SeatState> state =
    new AtomicReference<>(new SeatState(FREE, null, null));

boolean tryHold(HolderId who, Instant expiresAt) {
  for (;;) {
    SeatState cur = state.get();
    if (cur.status() != FREE) return false;
    SeatState next = new SeatState(HELD, who, expiresAt);
    if (state.compareAndSet(cur, next)) return true;
  }
}

boolean tryBook(HolderId who) {
  for (;;) {
    SeatState cur = state.get();
    if (cur.status() != HELD || !who.equals(cur.owner())) return false;
    SeatState next = new SeatState(BOOKED, who, null);
    if (state.compareAndSet(cur, next)) return true;
  }
}

Two counters both call tryHold: only one CAS wins. tryBook succeeds only when status is HELD and the owner matches — one coherent value, not a status CAS plus a separate holder write. The hold-then-book pair is still two arrows. Do not glue them with a lock that also charges the card. Claim HELD, release, charge, then tryBook. If charge fails, tryRelease back to FREE. If tryBook fails, you charged a seat you do not own — that is why owner lives on the state.

Illegal on one thread, still illegal with two

CLOSED → ACTIVE is not a concurrency bug. It is a domain bug that concurrency makes louder. Write the legal arrows first, as commands. Then make each command one step. A concurrent mess on an illegal setter is two bugs stacked. The state-machines page is the paper diagram; this page is what happens when two cashiers share that diagram.

Common mistakes

Exposing setStatus

Any caller can write CLOSED → ACTIVE. Commands like pay() and close() can refuse. A setter cannot.

Guarding with a read, then writing

if (status == ACTIVE) status = PAID is two steps. Two cashiers both pass the if. Use compareAndSet or lock the whole if-then.

SMS inside the latch

Holding a lock across I/O invites deadlock and double-wait. Claim PAID, release, then talk to the world. Retry the send, not the pay.

Vending, elevator, ATM — same arrow

A vending machine IDLE → COLLECTING → DISPENSING: two coins arriving as two threads is unusual in a single kiosk, but a GUI plus a timeout thread is two actors on one state. tryStartVend must win once. An elevator cab MOVING → STOPPED: two requests to stop at 5 must not both open doors twice as two arrivals. An ATM session AUTHED → DISPENSING: a second withdraw command on the same session must lose. The nouns change. The rule does not: commands, one winner per arrow, side effects after.

If the object already has a State pattern, the transition methods on the state objects still need CAS or a lock. The pattern names the states. It does not make the arrow atomic.

Exercises

  1. Spot 101: FREE → OCCUPIED → FREE on exit. Which command is tryOccupy’s twin on the way out? Can two exits both succeed?
  2. A webhook retries pay() after a timeout. Ticket is already PAID. What should the second call return, and what should the till do?
  3. Why is CLOSED → ACTIVE illegal even if only one thread runs? What API shape prevents it?
  4. A timer thread expires HELD seats while a counter calls tryBook. Which CAS must the timer use so a late book cannot lose the seat and the money?

Write the legal arrows first. Then make each a command that returns boolean. Then make that command one step. Side effects after the win. That order is the round.

A State pattern without an atomic arrow is a named race. CAS without commands is an atomic setter. You want both: names that refuse, and a step that cannot split.

Read next

Where the arrow still splits

tryPay succeeds, then you write paidAt on a plain field without a handshake: another thread sees PAID and a null Instant. Glue payload and status, or write payload first under the same lock. A webhook that calls setStatus(PAID) because tryPay returned false “but we know it paid” reopens CLOSED tickets. Trust the command. If the provider says paid and the ticket is already PAID, that is idempotent success, not a setter.

Timeouts that auto-close HELD seats from a timer thread are a second actor on the arrow. tryRelease must CAS the whole SeatState from HELD → FREE only if the owner still matches. Otherwise a late book and a timeout both “win.”

Takeaway

The arrow is the critical section. Commands refuse illegal moves and lose cleanly when a second cashier arrives. CAS or a lock makes the arrow one step. Side effects live after the win. No setters on status.

Hold-then-book is two arrows on one atomic SeatState — status, owner, and expiry together. A timer that expires holds is a second actor on the same arrow. Glue payload with status in one CAS. Idempotent pay is a false from tryPay, not a second debit. Paper arrows first, then one step each.

A State pattern names the rooms. CAS or a lock is the latch on each door. You need both.