Add lld-parking-lot module: thread-safe parking lot with Strategy, Observer and CAS-based spot claiming, 10 tests incl. 64-thread race
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,27 @@
|
||||
<?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>
|
||||
|
||||
<groupId>com.ankurm.interviewlab</groupId>
|
||||
<artifactId>lld-parking-lot</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>lld-parking-lot</name>
|
||||
<description>Low-level design interview: a thread-safe parking lot in plain Java</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<junit.version>6.1.3</junit.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
Executable
+17
@@ -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
|
||||
Executable
+7
@@ -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
|
||||
@@ -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<ParkingSpot> candidates(List<ParkingFloor> floors, Vehicle vehicle);
|
||||
}
|
||||
+9
@@ -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<SpotSize, Long> freeBySize);
|
||||
}
|
||||
@@ -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<ParkingSpot> candidates(List<ParkingFloor> 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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<VehicleType, BigDecimal> ratePerHour;
|
||||
private final Duration grace;
|
||||
|
||||
public HourlyPricing(Map<VehicleType, BigDecimal> 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));
|
||||
}
|
||||
}
|
||||
+15
@@ -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<ParkingSpot> candidates(List<ParkingFloor> floors, Vehicle vehicle) {
|
||||
return floors.stream()
|
||||
.flatMap(f -> f.spots().stream())
|
||||
.filter(ParkingSpot::isFree)
|
||||
.filter(s -> s.size().canHold(vehicle.type()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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<ParkingSpot> 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<ParkingSpot>();
|
||||
for (int i = 0; i < sizes.length; i++) {
|
||||
spots.add(new ParkingSpot("F" + number + "-" + (i + 1), number, sizes[i]));
|
||||
}
|
||||
return new ParkingFloor(number, spots);
|
||||
}
|
||||
}
|
||||
@@ -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<ParkingFloor> floors;
|
||||
private final Map<String, ParkingSpot> spotsById = new HashMap<>();
|
||||
private final AllocationStrategy allocation;
|
||||
private final PricingStrategy pricing;
|
||||
private final Clock clock;
|
||||
|
||||
private final Set<String> platesInside = ConcurrentHashMap.newKeySet();
|
||||
private final Map<String, Ticket> activeTickets = new ConcurrentHashMap<>();
|
||||
private final AtomicLong ticketSequence = new AtomicLong();
|
||||
private final List<AvailabilityListener> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
public ParkingLot(List<ParkingFloor> 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<SpotSize, Long> freeBySize() {
|
||||
Map<SpotSize, Long> 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<SpotSize, Long> snapshot = freeBySize();
|
||||
for (AvailabilityListener l : listeners) l.changed(snapshot);
|
||||
}
|
||||
|
||||
public List<ParkingFloor> floors() { return new ArrayList<>(floors); }
|
||||
}
|
||||
@@ -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<Vehicle> 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<Vehicle> 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 + ")"; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<Long> 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<String> spots = ConcurrentHashMap.newKeySet();
|
||||
try (var pool = Executors.newFixedThreadPool(threads)) {
|
||||
List<Future<?>> 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<Future<?>> 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user