From a5fbf4597152a8bcd3d5980a65d083e993a0434e Mon Sep 17 00:00:00 2001 From: asmhatre Date: Thu, 22 Oct 2026 08:01:55 +0000 Subject: [PATCH] Add lld-parking-lot module: thread-safe parking lot with Strategy, Observer and CAS-based spot claiming, 10 tests incl. 64-thread race --- README.md | 3 +- lld-parking-lot/README.md | 53 ++++++ lld-parking-lot/output/01-walkthrough.txt | 16 ++ lld-parking-lot/output/02-test-results.txt | 11 ++ lld-parking-lot/pom.xml | 27 +++ lld-parking-lot/scripts/run-all.sh | 17 ++ lld-parking-lot/scripts/run.sh | 7 + .../parking/AllocationStrategy.java | 12 ++ .../parking/AvailabilityListener.java | 9 + .../interviewlab/parking/BestFitStrategy.java | 17 ++ .../com/ankurm/interviewlab/parking/Demo.java | 58 ++++++ .../interviewlab/parking/HourlyPricing.java | 34 ++++ .../parking/NearestFirstStrategy.java | 15 ++ .../parking/ParkingException.java | 18 ++ .../interviewlab/parking/ParkingFloor.java | 19 ++ .../interviewlab/parking/ParkingLot.java | 90 ++++++++++ .../interviewlab/parking/ParkingSpot.java | 36 ++++ .../interviewlab/parking/PricingStrategy.java | 9 + .../ankurm/interviewlab/parking/Receipt.java | 8 + .../ankurm/interviewlab/parking/SpotSize.java | 11 ++ .../ankurm/interviewlab/parking/Ticket.java | 6 + .../ankurm/interviewlab/parking/Vehicle.java | 12 ++ .../interviewlab/parking/VehicleType.java | 14 ++ .../interviewlab/parking/ParkingLotTest.java | 169 ++++++++++++++++++ 24 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 lld-parking-lot/README.md create mode 100644 lld-parking-lot/output/01-walkthrough.txt create mode 100644 lld-parking-lot/output/02-test-results.txt create mode 100644 lld-parking-lot/pom.xml create mode 100755 lld-parking-lot/scripts/run-all.sh create mode 100755 lld-parking-lot/scripts/run.sh create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AllocationStrategy.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AvailabilityListener.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/BestFitStrategy.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Demo.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/HourlyPricing.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/NearestFirstStrategy.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingException.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingFloor.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingLot.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingSpot.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/PricingStrategy.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Receipt.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/SpotSize.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Ticket.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Vehicle.java create mode 100644 lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/VehicleType.java create mode 100644 lld-parking-lot/src/test/java/com/ankurm/interviewlab/parking/ParkingLotTest.java diff --git a/README.md b/README.md index 7375d0c..e2f8df7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ One module per article. This repository currently holds: | Module | Article | Status | |---|---|---| -| [`collections`](collections/) | Top 40 Java Collections Interview Questions (HashMap Internals to Fail-Fast Iterators) | this commit | +| [`collections`](collections/) | Top 40 Java Collections Interview Questions (HashMap Internals to Fail-Fast Iterators) | published | +| [`lld-parking-lot`](lld-parking-lot/) | Low-Level Design Interview in Java: Parking Lot System, Step by Step | this commit | ## `collections` module diff --git a/lld-parking-lot/README.md b/lld-parking-lot/README.md new file mode 100644 index 0000000..b5f8752 --- /dev/null +++ b/lld-parking-lot/README.md @@ -0,0 +1,53 @@ +# lld-parking-lot + +A thread-safe parking lot in plain Java, built the way a low-level-design interview expects: +requirements, entities, patterns, concurrency, extensions. Companion to +[Low-Level Design Interview in Java: Parking Lot System, Step by Step](https://ankurm.com/low-level-design-interview-parking-lot-system-java/). + +No framework, no runtime dependencies. JUnit 6 is used for tests only. + +## Versions + +| Component | Version | Verified against | +|---|---|---| +| JDK | 25.0.4.1+1 (Temurin LTS); also compiled and run with `--release 21` on OpenJDK 21 | `java -version`; the walkthrough output is byte-identical on both | +| JUnit Jupiter | 6.1.3 | `org.junit.jupiter:junit-jupiter` metadata on Maven Central | +| Maven | 3.9.11 | `mvn -version` | + +## Run it + +```bash +cd lld-parking-lot +./scripts/run.sh # compiles with javac, prints the walkthrough (no Maven needed) +mvn test # 10 tests, including two concurrency tests +./scripts/run-all.sh # regenerates everything in output/ +``` + +## Layout + +``` +src/main/java/com/ankurm/interviewlab/parking/ + VehicleType, SpotSize enums; SpotSize.canHold(VehicleType) is the only "does it fit" rule + Vehicle, Ticket, Receipt immutable records + ParkingSpot occupancy is one AtomicReference compare-and-set + ParkingFloor ordered spots + AllocationStrategy Strategy: NearestFirstStrategy, BestFitStrategy + PricingStrategy Strategy: HourlyPricing (grace period, partial hours round up) + AvailabilityListener Observer: the entrance display board + ParkingException LotFull, AlreadyParked, UnknownTicket + ParkingLot the aggregate: park(), exit(), freeSpots() + Demo prints output/01-walkthrough.txt +src/test/java/.../ParkingLotTest 10 tests; Clock is injected so nothing sleeps +output/ captured real output +``` + +## Captured output + +| File | What it shows | +|---|---| +| `output/01-walkthrough.txt` | Enter, leave, fail three ways, and the two allocation strategies placing the same motorcycle differently | +| `output/02-test-results.txt` | One line per test, generated from the Surefire XML report | + +## License + +MIT, see the repository root. diff --git a/lld-parking-lot/output/01-walkthrough.txt b/lld-parking-lot/output/01-walkthrough.txt new file mode 100644 index 0000000..235ea02 --- /dev/null +++ b/lld-parking-lot/output/01-walkthrough.txt @@ -0,0 +1,16 @@ +== 1. entering and leaving == + board: {SMALL=1, MEDIUM=2, LARGE=1} + board: {SMALL=1, MEDIUM=1, LARGE=1} + board: {SMALL=1, MEDIUM=1, LARGE=0} +bike -> F0-1, car -> F0-2, truck -> F0-3 + board: {SMALL=1, MEDIUM=2, LARGE=0} +car stayed 130 min, fee = 60 + +== 2. failures == +second MH03-TRK : MH03-TRK is already inside the lot +another truck : no free spot for TRUCK MH04-TRK +reused ticket : unknown or already used ticket T-2 + +== 3. the strategy decides where the motorcycle goes == +NearestFirstStrategy puts a motorcycle in F0-1 +BestFitStrategy puts a motorcycle in F1-1 diff --git a/lld-parking-lot/output/02-test-results.txt b/lld-parking-lot/output/02-test-results.txt new file mode 100644 index 0000000..3f48cda --- /dev/null +++ b/lld-parking-lot/output/02-test-results.txt @@ -0,0 +1,11 @@ +PASS truckIsRejectedWhenOnlyMediumSpotsAreFree +PASS listenersSeeEveryChange +PASS samePlateCannotBeInsideTwice +PASS bestFitKeepsLargeSpotsFreeForTrucks +PASS concurrentDuplicatePlateAdmitsExactlyOne +PASS failedParkDoesNotLeaveThePlateReserved +PASS nearestFirstTakesTheFirstFreeSpotThatFits +PASS feeRoundsPartialHoursUpAndHasAGracePeriod +PASS concurrentParkingNeverDoubleBooksASpot +PASS exitFreesTheSpotAndTheTicketIsSingleUse +10 tests, 0 failures diff --git a/lld-parking-lot/pom.xml b/lld-parking-lot/pom.xml new file mode 100644 index 0000000..521df91 --- /dev/null +++ b/lld-parking-lot/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + com.ankurm.interviewlab + lld-parking-lot + 1.0.0 + lld-parking-lot + Low-level design interview: a thread-safe parking lot in plain Java + + + 21 + UTF-8 + 6.1.3 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + diff --git a/lld-parking-lot/scripts/run-all.sh b/lld-parking-lot/scripts/run-all.sh new file mode 100755 index 0000000..4e05fac --- /dev/null +++ b/lld-parking-lot/scripts/run-all.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Regenerates every transcript in output/. Needs JDK 21+ and Maven 3.9+. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p output +./scripts/run.sh > output/01-walkthrough.txt 2>&1 +mvn -B -q test > /dev/null +python3 - <<'PY' > output/02-test-results.txt +import glob, xml.etree.ElementTree as ET +total = fail = 0 +for f in sorted(glob.glob("target/surefire-reports/TEST-*.xml")): + for tc in ET.parse(f).getroot().iter("testcase"): + bad = tc.find("failure") is not None or tc.find("error") is not None + total += 1; fail += bad + print(("FAIL " if bad else "PASS ") + tc.get("name")) +print(f"{total} tests, {fail} failures") +PY diff --git a/lld-parking-lot/scripts/run.sh b/lld-parking-lot/scripts/run.sh new file mode 100755 index 0000000..26a0637 --- /dev/null +++ b/lld-parking-lot/scripts/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Compiles with plain javac (no Maven needed) and runs the walkthrough. Needs JDK 21 or newer. +set -euo pipefail +cd "$(dirname "$0")/.." +rm -rf target/plain && mkdir -p target/plain +javac -d target/plain $(find src/main/java -name '*.java') +java -cp target/plain com.ankurm.interviewlab.parking.Demo diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AllocationStrategy.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AllocationStrategy.java new file mode 100644 index 0000000..e004b85 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AllocationStrategy.java @@ -0,0 +1,12 @@ +package com.ankurm.interviewlab.parking; + +import java.util.List; + +/** + * Strategy pattern: decides the ORDER in which spots are offered to a vehicle. + * It never claims a spot itself, because claiming must be atomic and belongs to {@link ParkingSpot}. + * The lot walks the returned list and keeps the first spot that {@code tryOccupy} wins. + */ +public interface AllocationStrategy { + List candidates(List floors, Vehicle vehicle); +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AvailabilityListener.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AvailabilityListener.java new file mode 100644 index 0000000..91028e7 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/AvailabilityListener.java @@ -0,0 +1,9 @@ +package com.ankurm.interviewlab.parking; + +import java.util.Map; + +/** Observer pattern: the display board at the entrance implements this. */ +@FunctionalInterface +public interface AvailabilityListener { + void changed(Map freeBySize); +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/BestFitStrategy.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/BestFitStrategy.java new file mode 100644 index 0000000..d97a293 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/BestFitStrategy.java @@ -0,0 +1,17 @@ +package com.ankurm.interviewlab.parking; + +import java.util.Comparator; +import java.util.List; + +/** Smallest spot that fits, nearest first among equals. Keeps large spots free for trucks. */ +public final class BestFitStrategy implements AllocationStrategy { + @Override + public List candidates(List floors, Vehicle vehicle) { + return floors.stream() + .flatMap(f -> f.spots().stream()) + .filter(ParkingSpot::isFree) + .filter(s -> s.size().canHold(vehicle.type())) + .sorted(Comparator.comparing(ParkingSpot::size).thenComparingInt(ParkingSpot::floor)) + .toList(); + } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Demo.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Demo.java new file mode 100644 index 0000000..a9cb8a4 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Demo.java @@ -0,0 +1,58 @@ +package com.ankurm.interviewlab.parking; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +/** Prints the whole story once; the transcript is committed as output/01-walkthrough.txt. */ +public final class Demo { + + /** A clock we can move by hand, so "2 hours later" needs no sleeping. */ + static final class ManualClock extends Clock { + private Instant now = Instant.parse("2026-10-22T09:00:00Z"); + void advance(Duration d) { now = now.plus(d); } + @Override public Instant instant() { return now; } + @Override public java.time.ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(java.time.ZoneId zone) { return this; } + } + + public static void main(String[] args) { + System.out.println("== 1. entering and leaving =="); + var clock = new ManualClock(); + var lot = new ParkingLot( + List.of(ParkingFloor.of(0, SpotSize.SMALL, SpotSize.MEDIUM, SpotSize.LARGE), + ParkingFloor.of(1, SpotSize.SMALL, SpotSize.MEDIUM)), + new BestFitStrategy(), HourlyPricing.standard(), clock); + lot.addListener(free -> System.out.println(" board: " + free)); + + Ticket bike = lot.park(new Vehicle("MH01-BIKE", VehicleType.MOTORCYCLE)); + Ticket car = lot.park(new Vehicle("MH02-CAR", VehicleType.CAR)); + Ticket truck = lot.park(new Vehicle("MH03-TRK", VehicleType.TRUCK)); + System.out.println("bike -> " + bike.spotId() + ", car -> " + car.spotId() + ", truck -> " + truck.spotId()); + + clock.advance(Duration.ofHours(2).plusMinutes(10)); + Receipt r = lot.exit(car.id()); + System.out.println("car stayed " + r.duration().toMinutes() + " min, fee = " + r.fee()); + + System.out.println(); + System.out.println("== 2. failures =="); + try { lot.park(new Vehicle("MH03-TRK", VehicleType.TRUCK)); } + catch (ParkingException e) { System.out.println("second MH03-TRK : " + e.getMessage()); } + try { lot.park(new Vehicle("MH04-TRK", VehicleType.TRUCK)); } + catch (ParkingException e) { System.out.println("another truck : " + e.getMessage()); } + try { lot.exit(car.id()); } + catch (ParkingException e) { System.out.println("reused ticket : " + e.getMessage()); } + + System.out.println(); + System.out.println("== 3. the strategy decides where the motorcycle goes =="); + for (AllocationStrategy s : List.of(new NearestFirstStrategy(), new BestFitStrategy())) { + var l = new ParkingLot( + List.of(ParkingFloor.of(0, SpotSize.LARGE), ParkingFloor.of(1, SpotSize.SMALL)), + s, HourlyPricing.standard(), clock); + System.out.println(s.getClass().getSimpleName() + " puts a motorcycle in " + + l.park(new Vehicle("MH05-BIKE", VehicleType.MOTORCYCLE)).spotId()); + } + } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/HourlyPricing.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/HourlyPricing.java new file mode 100644 index 0000000..69121d0 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/HourlyPricing.java @@ -0,0 +1,34 @@ +package com.ankurm.interviewlab.parking; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.Map; + +/** + * Per-hour rate by vehicle type, partial hours rounded UP, with a free grace period. + * Rates are whole rupees to keep the example free of rounding noise; money would be BigDecimal in production anyway. + */ +public final class HourlyPricing implements PricingStrategy { + private final Map ratePerHour; + private final Duration grace; + + public HourlyPricing(Map ratePerHour, Duration grace) { + this.ratePerHour = Map.copyOf(ratePerHour); + this.grace = grace; + } + + public static HourlyPricing standard() { + return new HourlyPricing(Map.of( + VehicleType.MOTORCYCLE, BigDecimal.valueOf(10), + VehicleType.CAR, BigDecimal.valueOf(20), + VehicleType.TRUCK, BigDecimal.valueOf(40)), Duration.ofMinutes(15)); + } + + @Override + public BigDecimal fee(Vehicle vehicle, Duration stayed) { + if (stayed.compareTo(grace) <= 0) return BigDecimal.ZERO; + long minutes = stayed.toMinutes() + (stayed.toSecondsPart() > 0 ? 1 : 0); + long hours = Math.max(1, (minutes + 59) / 60); + return ratePerHour.get(vehicle.type()).multiply(BigDecimal.valueOf(hours)); + } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/NearestFirstStrategy.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/NearestFirstStrategy.java new file mode 100644 index 0000000..9c15c5a --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/NearestFirstStrategy.java @@ -0,0 +1,15 @@ +package com.ankurm.interviewlab.parking; + +import java.util.List; + +/** Lowest floor, then lowest spot number. Fast for the driver, wasteful for big spots. */ +public final class NearestFirstStrategy implements AllocationStrategy { + @Override + public List candidates(List floors, Vehicle vehicle) { + return floors.stream() + .flatMap(f -> f.spots().stream()) + .filter(ParkingSpot::isFree) + .filter(s -> s.size().canHold(vehicle.type())) + .toList(); + } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingException.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingException.java new file mode 100644 index 0000000..77b146a --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingException.java @@ -0,0 +1,18 @@ +package com.ankurm.interviewlab.parking; + +/** Base type so callers can catch one thing; the nested types say what actually went wrong. */ +public abstract class ParkingException extends RuntimeException { + protected ParkingException(String message) { super(message); } + + public static final class LotFull extends ParkingException { + public LotFull(Vehicle v) { super("no free spot for " + v.type() + " " + v.plate()); } + } + + public static final class AlreadyParked extends ParkingException { + public AlreadyParked(String plate) { super(plate + " is already inside the lot"); } + } + + public static final class UnknownTicket extends ParkingException { + public UnknownTicket(String id) { super("unknown or already used ticket " + id); } + } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingFloor.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingFloor.java new file mode 100644 index 0000000..b7bd182 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingFloor.java @@ -0,0 +1,19 @@ +package com.ankurm.interviewlab.parking; + +import java.util.List; + +/** A floor is just an ordered group of spots; the order is the "nearest to the entrance" order. */ +public record ParkingFloor(int number, List spots) { + public ParkingFloor { + spots = List.copyOf(spots); + } + + /** Convenience factory: {@code ParkingFloor.of(0, SMALL, SMALL, MEDIUM, LARGE)} creates spots F0-1..F0-4. */ + public static ParkingFloor of(int number, SpotSize... sizes) { + var spots = new java.util.ArrayList(); + for (int i = 0; i < sizes.length; i++) { + spots.add(new ParkingSpot("F" + number + "-" + (i + 1), number, sizes[i])); + } + return new ParkingFloor(number, spots); + } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingLot.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingLot.java new file mode 100644 index 0000000..4fdf9dc --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingLot.java @@ -0,0 +1,90 @@ +package com.ankurm.interviewlab.parking; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; + +/** + * The aggregate the interviewer is really asking about. + * + * Thread-safety without a big lock: + * - a spot is claimed with one compare-and-set ({@link ParkingSpot#tryOccupy}); + * - "one plate inside at most once" is a concurrent set add; + * - tickets live in a ConcurrentHashMap, and removing one is what makes a ticket single-use. + */ +public final class ParkingLot { + private final List floors; + private final Map spotsById = new HashMap<>(); + private final AllocationStrategy allocation; + private final PricingStrategy pricing; + private final Clock clock; + + private final Set platesInside = ConcurrentHashMap.newKeySet(); + private final Map activeTickets = new ConcurrentHashMap<>(); + private final AtomicLong ticketSequence = new AtomicLong(); + private final List listeners = new CopyOnWriteArrayList<>(); + + public ParkingLot(List floors, AllocationStrategy allocation, PricingStrategy pricing, Clock clock) { + this.floors = List.copyOf(floors); + this.allocation = allocation; + this.pricing = pricing; + this.clock = clock; + for (ParkingFloor f : floors) for (ParkingSpot s : f.spots()) spotsById.put(s.id(), s); + } + + public void addListener(AvailabilityListener l) { listeners.add(l); } + + public Ticket park(Vehicle vehicle) { + if (!platesInside.add(vehicle.plate())) throw new ParkingException.AlreadyParked(vehicle.plate()); + for (ParkingSpot spot : allocation.candidates(floors, vehicle)) { + if (spot.tryOccupy(vehicle)) { // lost a race? try the next candidate + Ticket t = new Ticket("T-" + ticketSequence.incrementAndGet(), vehicle, spot.id(), clock.instant()); + activeTickets.put(t.id(), t); + publish(); + return t; + } + } + platesInside.remove(vehicle.plate()); // undo the plate reservation + throw new ParkingException.LotFull(vehicle); + } + + public Receipt exit(String ticketId) { + Ticket t = activeTickets.remove(ticketId); // atomic: only one caller can get the ticket + if (t == null) throw new ParkingException.UnknownTicket(ticketId); + Instant now = clock.instant(); + Duration stayed = Duration.between(t.entry(), now); + var receipt = new Receipt(t, now, stayed, pricing.fee(t.vehicle(), stayed)); + spotsById.get(t.spotId()).release(); + platesInside.remove(t.vehicle().plate()); + publish(); + return receipt; + } + + public long freeSpots(SpotSize size) { + return floors.stream().flatMap(f -> f.spots().stream()) + .filter(s -> s.size() == size && s.isFree()).count(); + } + + public Map freeBySize() { + Map m = new EnumMap<>(SpotSize.class); + for (SpotSize s : SpotSize.values()) m.put(s, freeSpots(s)); + return m; + } + + private void publish() { + if (listeners.isEmpty()) return; + Map snapshot = freeBySize(); + for (AvailabilityListener l : listeners) l.changed(snapshot); + } + + public List floors() { return new ArrayList<>(floors); } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingSpot.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingSpot.java new file mode 100644 index 0000000..89c03eb --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/ParkingSpot.java @@ -0,0 +1,36 @@ +package com.ankurm.interviewlab.parking; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +/** + * One physical spot. Occupancy is a single compare-and-set, which is the whole concurrency story: + * two cars racing for the same spot cannot both win, and no lock is needed. + */ +public final class ParkingSpot { + private final String id; + private final int floor; + private final SpotSize size; + private final AtomicReference occupant = new AtomicReference<>(); + + public ParkingSpot(String id, int floor, SpotSize size) { + this.id = id; + this.floor = floor; + this.size = size; + } + + public String id() { return id; } + public int floor() { return floor; } + public SpotSize size() { return size; } + public boolean isFree() { return occupant.get() == null; } + public Optional occupant() { return Optional.ofNullable(occupant.get()); } + + /** Atomically claims the spot. Returns false if another thread got there first or the vehicle does not fit. */ + public boolean tryOccupy(Vehicle v) { + return size.canHold(v.type()) && occupant.compareAndSet(null, v); + } + + public void release() { occupant.set(null); } + + @Override public String toString() { return id + "(" + size + ")"; } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/PricingStrategy.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/PricingStrategy.java new file mode 100644 index 0000000..00d232a --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/PricingStrategy.java @@ -0,0 +1,9 @@ +package com.ankurm.interviewlab.parking; + +import java.math.BigDecimal; +import java.time.Duration; + +/** Strategy pattern again: pricing rules change far more often than parking rules. */ +public interface PricingStrategy { + BigDecimal fee(Vehicle vehicle, Duration stayed); +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Receipt.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Receipt.java new file mode 100644 index 0000000..a0aaf31 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Receipt.java @@ -0,0 +1,8 @@ +package com.ankurm.interviewlab.parking; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; + +/** What the driver gets at the gate: how long they stayed and what they owe. */ +public record Receipt(Ticket ticket, Instant exit, Duration duration, BigDecimal fee) {} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/SpotSize.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/SpotSize.java new file mode 100644 index 0000000..02d3c82 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/SpotSize.java @@ -0,0 +1,11 @@ +package com.ankurm.interviewlab.parking; + +/** Physical size of a spot. Ordered small to large so "fits" is a single comparison. */ +public enum SpotSize { + SMALL, MEDIUM, LARGE; + + /** A spot can hold a vehicle when it is at least as big as the vehicle's minimum spot. */ + public boolean canHold(VehicleType type) { + return this.ordinal() >= type.minimumSpot().ordinal(); + } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Ticket.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Ticket.java new file mode 100644 index 0000000..242c2d7 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Ticket.java @@ -0,0 +1,6 @@ +package com.ankurm.interviewlab.parking; + +import java.time.Instant; + +/** Issued at entry, presented at exit. Immutable; the lot never mutates a ticket. */ +public record Ticket(String id, Vehicle vehicle, String spotId, Instant entry) {} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Vehicle.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Vehicle.java new file mode 100644 index 0000000..1546157 --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/Vehicle.java @@ -0,0 +1,12 @@ +package com.ankurm.interviewlab.parking; + +import java.util.Objects; + +/** Immutable. The plate is the identity: one plate can be inside the lot at most once. */ +public record Vehicle(String plate, VehicleType type) { + public Vehicle { + Objects.requireNonNull(plate, "plate"); + Objects.requireNonNull(type, "type"); + if (plate.isBlank()) throw new IllegalArgumentException("plate must not be blank"); + } +} diff --git a/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/VehicleType.java b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/VehicleType.java new file mode 100644 index 0000000..cf09d0c --- /dev/null +++ b/lld-parking-lot/src/main/java/com/ankurm/interviewlab/parking/VehicleType.java @@ -0,0 +1,14 @@ +package com.ankurm.interviewlab.parking; + +/** Vehicle categories and the smallest spot each one fits in. */ +public enum VehicleType { + MOTORCYCLE(SpotSize.SMALL), + CAR(SpotSize.MEDIUM), + TRUCK(SpotSize.LARGE); + + private final SpotSize minimumSpot; + + VehicleType(SpotSize minimumSpot) { this.minimumSpot = minimumSpot; } + + public SpotSize minimumSpot() { return minimumSpot; } +} diff --git a/lld-parking-lot/src/test/java/com/ankurm/interviewlab/parking/ParkingLotTest.java b/lld-parking-lot/src/test/java/com/ankurm/interviewlab/parking/ParkingLotTest.java new file mode 100644 index 0000000..e1474cd --- /dev/null +++ b/lld-parking-lot/src/test/java/com/ankurm/interviewlab/parking/ParkingLotTest.java @@ -0,0 +1,169 @@ +package com.ankurm.interviewlab.parking; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class ParkingLotTest { + + /** Hand-moved clock: tests never sleep. */ + static final class ManualClock extends Clock { + private volatile Instant now = Instant.parse("2026-10-22T09:00:00Z"); + void advance(Duration d) { now = now.plus(d); } + @Override public Instant instant() { return now; } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + } + + private static Vehicle car(String plate) { return new Vehicle(plate, VehicleType.CAR); } + + private ParkingLot lot(AllocationStrategy s, ManualClock c, ParkingFloor... floors) { + return new ParkingLot(List.of(floors), s, HourlyPricing.standard(), c); + } + + @Test + void bestFitKeepsLargeSpotsFreeForTrucks() { + var lot = lot(new BestFitStrategy(), new ManualClock(), + ParkingFloor.of(0, SpotSize.LARGE, SpotSize.MEDIUM)); + assertEquals("F0-2", lot.park(car("A")).spotId()); // medium, not the nearer large + assertEquals(1, lot.freeSpots(SpotSize.LARGE)); + } + + @Test + void nearestFirstTakesTheFirstFreeSpotThatFits() { + var lot = lot(new NearestFirstStrategy(), new ManualClock(), + ParkingFloor.of(0, SpotSize.LARGE, SpotSize.MEDIUM)); + assertEquals("F0-1", lot.park(car("A")).spotId()); // the large spot is nearer + } + + @Test + void truckIsRejectedWhenOnlyMediumSpotsAreFree() { + var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM)); + assertThrows(ParkingException.LotFull.class, () -> lot.park(new Vehicle("T", VehicleType.TRUCK))); + } + + @Test + void failedParkDoesNotLeaveThePlateReserved() { + var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM)); + var truck = new Vehicle("T", VehicleType.TRUCK); + assertThrows(ParkingException.LotFull.class, () -> lot.park(truck)); + assertThrows(ParkingException.LotFull.class, () -> lot.park(truck)); // not AlreadyParked + } + + @Test + void samePlateCannotBeInsideTwice() { + var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM)); + lot.park(car("A")); + assertThrows(ParkingException.AlreadyParked.class, () -> lot.park(car("A"))); + } + + @Test + void feeRoundsPartialHoursUpAndHasAGracePeriod() { + var clock = new ManualClock(); + var lot = lot(new BestFitStrategy(), clock, ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM)); + record Case(Duration stay, int expected) {} + for (var c : List.of( + new Case(Duration.ofMinutes(10), 0), // inside grace + new Case(Duration.ofMinutes(15), 0), // grace boundary is free + new Case(Duration.ofMinutes(16), 20), // 1 started hour + new Case(Duration.ofMinutes(60), 20), // exactly 1 hour + new Case(Duration.ofMinutes(61), 40))) { // 2 started hours + Ticket t = lot.park(car("P" + c.stay().toMinutes())); + clock.advance(c.stay()); + assertEquals(0, BigDecimal.valueOf(c.expected()).compareTo(lot.exit(t.id()).fee()), "stay " + c.stay()); + } + } + + @Test + void exitFreesTheSpotAndTheTicketIsSingleUse() { + var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM)); + Ticket t = lot.park(car("A")); + assertEquals(0, lot.freeSpots(SpotSize.MEDIUM)); + lot.exit(t.id()); + assertEquals(1, lot.freeSpots(SpotSize.MEDIUM)); + assertThrows(ParkingException.UnknownTicket.class, () -> lot.exit(t.id())); + assertDoesNotThrow(() -> lot.park(car("B"))); // spot is reusable + } + + @Test + void listenersSeeEveryChange() { + var lot = lot(new BestFitStrategy(), new ManualClock(), ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM)); + List seen = new ArrayList<>(); + lot.addListener(free -> seen.add(free.get(SpotSize.MEDIUM))); + Ticket t = lot.park(car("A")); + lot.park(car("B")); + lot.exit(t.id()); + assertEquals(List.of(1L, 0L, 1L), seen); + } + + /** 64 threads race for 10 spots: exactly 10 win, and no two winners share a spot. */ + @Test + void concurrentParkingNeverDoubleBooksASpot() throws Exception { + var lot = lot(new BestFitStrategy(), new ManualClock(), + ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM), + ParkingFloor.of(1, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM)); + int threads = 64; + var start = new CountDownLatch(1); + var parked = new AtomicInteger(); + var full = new AtomicInteger(); + Set spots = ConcurrentHashMap.newKeySet(); + try (var pool = Executors.newFixedThreadPool(threads)) { + List> fs = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + final int n = i; + fs.add(pool.submit(() -> { + start.await(); + try { + spots.add(lot.park(car("CAR-" + n)).spotId()); + parked.incrementAndGet(); + } catch (ParkingException.LotFull e) { + full.incrementAndGet(); + } + return null; + })); + } + start.countDown(); + for (Future f : fs) f.get(); + } + assertEquals(10, parked.get()); + assertEquals(54, full.get()); + assertEquals(10, spots.size(), "every winner got a different spot"); + assertEquals(0, lot.freeSpots(SpotSize.MEDIUM)); + } + + /** The same plate from many threads: exactly one gets in. */ + @Test + void concurrentDuplicatePlateAdmitsExactlyOne() throws Exception { + var lot = lot(new BestFitStrategy(), new ManualClock(), + ParkingFloor.of(0, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM, SpotSize.MEDIUM)); + var start = new CountDownLatch(1); + var ok = new AtomicInteger(); + try (var pool = Executors.newFixedThreadPool(16)) { + List> fs = new ArrayList<>(); + for (int i = 0; i < 16; i++) { + fs.add(pool.submit(() -> { + start.await(); + try { lot.park(car("SAME")); ok.incrementAndGet(); } + catch (ParkingException.AlreadyParked ignored) { } + return null; + })); + } + start.countDown(); + for (Future f : fs) f.get(); + } + assertEquals(1, ok.get()); + assertEquals(3, lot.freeSpots(SpotSize.MEDIUM)); + } +}