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:
2026-10-22 08:01:55 +00:00
parent d4b3a63615
commit a5fbf45971
24 changed files with 670 additions and 1 deletions
+2 -1
View File
@@ -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
+53
View File
@@ -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.
+16
View File
@@ -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
+27
View File
@@ -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>
+17
View File
@@ -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
+7
View File
@@ -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);
}
@@ -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));
}
}
@@ -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));
}
}