Cab Booking (Uber) β Implementation¶
Single-file Python implementation (
cab_booking.py, stdlib only, Python 3.10+): geo-indexed driver matching that can't double-book a driver, a validated trip state machine, Decimal fares with zone surge, and an in-memory Kafka pipeline for GPS updates. Tests:test_cab_booking.py.
βΆοΈ How to Run¶
cd python-low-level-design/cab-booking-uber
python3 cab_booking.py # deterministic demo
python3 -m unittest test_cab_booking -v # 20 tests, < 1 s
πΊοΈ Map of the File¶
| Section | Classes | Role |
|---|---|---|
| Vocabulary | CabStatus, TripStatus, CabType, BookingError family, Location |
Enums, domain errors, frozen value object with haversine distance_to |
| People | Rider, Driver |
Driver owns its status and only changes it with a compare-and-set (try_claim, compare_and_set) |
| Pricing | PricingStrategy, StandardPricing, SurgePricing, RateCard |
Strategy + Decorator, all money is Decimal rounded half-up to paise/cents |
| Geo | geohash_encode, geohash_bbox, geohash_neighbourhood, GeoIndex |
Redis-GEO-style index: sorted by geohash, radius = 9 range scans + exact filter |
| Events | KafkaBroker, KafkaMessage |
Partitioned logs, crc32(key) % n partitioning, per-group committed offsets |
| Surge | Zone, ZoneManager, compute_surge |
Zone id = geohash-6 cell, so point β zone is O(1) |
| Consumers | GPSLocationStreamProcessor, ZoneAnalyticsAggregator |
raw GPS β index + enriched topic; enriched β supply/demand β surge |
| Matching | DriverMatchingStrategy, NearestDriverMatching, HighestRatedDriverMatching |
Pure ranking of eligible candidates |
| Lifecycle | Trip (+ _TRANSITIONS table) |
State machine; owns its driver's status while active |
| Facade | CabBookingService |
Registration, location updates, request_ride (search β rank β claim), trip actions |
π Key Design Decisions¶
1. Matching = search β rank β claim (no double booking)¶
The geo search is a snapshot; by the time you act on it, another request may have taken the driver. So matching is split in three, and only the last step is authoritative:
for radius in self.SEARCH_RADII_KM: # 2 km β 5 km β 10 km
candidates = [eligible (driver, dist) from geo_index.search(pickup, radius)]
for driver in self._matching.rank(pickup, candidates):
if driver.try_claim(): # AVAILABLE β BOOKED, atomic
return driver
Driver.try_claim() is a compare-and-set under the driver's own lock. Losing a claim is not an error; the
request simply moves on to the next-ranked driver. That's why test_n_drivers_n_riders_each_driver_used_once
gets 12 trips with 12 distinct drivers, and why 16 riders racing for one driver produce exactly one trip.
In production the same CAS is SET driver:{id}:lock <trip> NX PX 15000 in Redis or
UPDATE drivers SET status='BOOKED' WHERE id=? AND status='AVAILABLE' (check rows affected).
Strategies only rank; they never mutate. That keeps them trivially testable and means a new policy (ETA-based, acceptance-rate-weighted) never has to re-implement the concurrency.
2. The trip state machine is a table, enforced under the trip lock¶
REQUESTED ββacceptβββΆ ACCEPTED ββarriveβββΆ DRIVER_ARRIVED ββstartβββΆ STARTED ββcompleteβββΆ COMPLETED
β β² β β
β ββdecline (re-match another driver) β
βββββββββββββ΄ββββββββββcancelβββββββββββββββββ΄βββΆ CANCELLED
Trip._move() rejects anything not in _TRANSITIONS with InvalidTransitionError. Every service action
runs under trip.lock, so "rider cancels" racing "driver starts" has exactly one winner and the driver's
status always matches (test_cancel_vs_start_race_has_one_winner). The old version let you cancel a
completed trip, which flipped a driver who might already be on someone else's trip back to AVAILABLE.
Driver status follows the trip: claimed β BOOKED, start() β ON_TRIP, complete()/cancel()/declined
β AVAILABLE. Lock order is always trip β driver; driver locks are leaves and nothing holds a driver
lock while taking another lock, so there is no deadlock cycle.
3. Offer / decline is modelled¶
request_ride claims a driver and creates the trip in REQUESTED (the offer). decline_trip (also what
an offer timeout would call) frees that driver, records them in declined_by, and re-runs the claim loop
excluding everyone who already declined. If nobody is left, the trip is cancelled with a reason.
4. One active trip per rider¶
request_ride reserves the rider in _active_trip_by_rider under the service lock before matching, so
two taps from the same rider can't both claim drivers. The reservation is released on failure and when the
trip reaches a terminal state.
5. GeoIndex works like Redis GEO, including its edge cases¶
- One sorted list of
(geohash, driver_id); a radius query picks the finest precision whose cell is at leastradiustall and wide, scans the centre cell plus its 8 neighbours (without the neighbours, drivers just across a cell edge are missed), then filters by exact haversine distance. - Cost: O(9Β·log N + M) for the query. Inserts here are O(N) (list insert); Redis uses a skiplist, O(log N).
- Neighbour longitudes wrap, so a search near Β±180Β° works (tested).
upsert(driver_id, location, ts)drops updates older than the indexed one. GPS events can arrive out of order and Kafka redelivers on retry; this makes the consumer idempotent.- It stores ids only. Availability and cab type are filtered by the caller (the production equivalent is
separate keys like
drivers:available:mini, removing a driver from the key when they're booked).
Geohash cell sizes (at the equator; width shrinks with cos(lat)): precision 5 β 4.9 Γ 4.9 km, 6 β 1.2 Γ 0.61 km, 7 β 153 Γ 153 m.
6. Money and surge¶
Rate cards are Decimal; StandardPricing converts distance/duration once and rounds to 2 places.
SurgePricing decorates any strategy. compute_surge(demand, supply) is a step function, and no demand
means no surge even with zero drivers (the old code surged an empty zone to 2.5Γ). The fare is quoted
and locked at request time (upfront pricing), and complete_trip charges the quote.
7. Kafka simulation semantics are honest¶
producepartitions bycrc32(key); Python'shash()is randomised per process and would not be stable.- Consumers
pollfrom the committed offset andcommitafter handling each message: at-least-once. Replays are harmless because the index upsert is timestamp-guarded. - Malformed GPS goes to
gps.dlqwith the error; the consumer moves on. ZoneAnalyticsAggregatortracks each driver's latest zone (a moving driver counts once), counts only AVAILABLE drivers as supply, and resets demand each window (tumbling).
π§© Where to Extend¶
| New requirement | Change |
|---|---|
| ETA-based matching | New DriverMatchingStrategy.rank() using a road-network ETA instead of straight-line distance |
| Batched / global matching | Collect requests for 1β2 s per zone, solve an assignment problem, then try_claim the chosen pairs |
| Offer timeout | Scheduler calls decline_trip(trip_id) if still REQUESTED after N seconds |
| Cancellation fee | In Trip.cancel, charge if status was DRIVER_ARRIVED or after a grace period |
| Pool rides | Driver gets seats; claim becomes "decrement seats if β₯ needed" (still a CAS) |
| Scheduled rides | A ScheduledRequest that triggers request_ride T-10 min; widen radius as T approaches |
| New cab type | Add the enum value and a RATE_CARDS entry; no class changes |
ποΈ Location Update Flow¶
Driver app ββGPS every ~3 sβββΆ gps.raw.updates (key = driver_id)
β
GPSLocationStreamProcessor.poll()
βββ validate (bad β gps.dlq)
βββ GeoIndex.upsert(driver, loc, ts) (stale ts β ignored)
βββ ZoneManager.zone_for(loc) (geohash-6)
βββ produce gps.enriched.locations, commit offset
β
ZoneAnalyticsAggregator.aggregate()
βββ latest zone per driver, AVAILABLE only
βββ demand = requests in window
βββ Zone.update_supply_demand β surge
βββ produce gps.zone.driver_counts
ποΈ Database Schema¶
The production schema (PostgreSQL + PostGIS, Redis keys) is in DB_SCHEMA.md.
π¦ Full Source¶
"""
Cab Booking Service (Uber/Ola) - Low Level Design
-------------------------------------------------
Core flows : register riders/drivers, stream driver GPS, request a ride, match a
nearby driver, run the trip state machine, price with zone surge.
Patterns : Strategy (pricing, driver ranking), Decorator (surge on top of base
fare), State machine (Trip), Facade (CabBookingService).
Concurrency: a driver is claimed with a compare-and-set under the driver's own
lock, so two concurrent requests can never book the same driver.
Trip transitions are validated under the trip's lock, so "rider
cancels" racing "driver starts" has exactly one winner.
Lock order is always trip -> driver (driver locks are leaves).
Infra sims : GeoIndex (Redis GEO), KafkaBroker (topics/partitions/offsets),
geohash-cell zones for surge. Stdlib only, Python 3.10+.
"""
from __future__ import annotations
import bisect
import itertools
import math
import random
import threading
import zlib
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal
from enum import Enum
from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple
MONEY = Decimal("0.01")
def to_money(value: Decimal) -> Decimal:
return value.quantize(MONEY, rounding=ROUND_HALF_UP)
# ============================================================
# Enums, errors, value objects
# ============================================================
class CabStatus(Enum):
AVAILABLE = "Available" # online and matchable
BOOKED = "Booked" # claimed for a trip (offered or accepted), not yet driving it
ON_TRIP = "On Trip" # rider on board
OFFLINE = "Offline"
class TripStatus(Enum):
REQUESTED = "Requested" # driver reserved, offer pending
ACCEPTED = "Accepted" # driver accepted, en route to pickup
DRIVER_ARRIVED = "Driver Arrived"
STARTED = "Started"
COMPLETED = "Completed"
CANCELLED = "Cancelled"
class CabType(Enum):
MINI = "Mini"
SEDAN = "Sedan"
SUV = "SUV"
PREMIUM = "Premium"
AUTO = "Auto"
class BookingError(Exception):
"""Base class for domain errors."""
class NotFoundError(BookingError):
pass
class NoDriverAvailableError(BookingError):
pass
class RiderBusyError(BookingError):
pass
class InvalidTransitionError(BookingError):
pass
@dataclass(frozen=True)
class Location:
lat: float
lng: float
def __post_init__(self) -> None:
if not (-90 <= self.lat <= 90 and -180 <= self.lng <= 180):
raise ValueError(f"invalid coordinates {self.lat}, {self.lng}")
def distance_to(self, other: Location) -> float:
"""Great-circle (haversine) distance in km."""
r = 6371.0
d_lat = math.radians(other.lat - self.lat)
d_lng = math.radians(other.lng - self.lng)
a = (math.sin(d_lat / 2) ** 2
+ math.cos(math.radians(self.lat)) * math.cos(math.radians(other.lat))
* math.sin(d_lng / 2) ** 2)
return 2 * r * math.asin(math.sqrt(min(1.0, a)))
def to_dict(self) -> dict:
return {"lat": self.lat, "lng": self.lng}
def __str__(self) -> str:
return f"({self.lat:.4f}, {self.lng:.4f})"
# ============================================================
# Rider & Driver
# ============================================================
class Rider:
def __init__(self, rider_id: str, name: str, phone: str):
self.rider_id = rider_id
self.name = name
self.phone = phone
def __str__(self) -> str:
return f"{self.name} ({self.phone})"
class Driver:
"""Driver status changes only via compare-and-set under self._lock."""
def __init__(self, driver_id: str, name: str, phone: str,
license_number: str, cab_type: CabType):
self.driver_id = driver_id
self.name = name
self.phone = phone
self.license_number = license_number
self.cab_type = cab_type
self._status = CabStatus.AVAILABLE
self._rating = 5.0
self._rated_trips = 0
self._lock = threading.Lock()
@property
def status(self) -> CabStatus:
return self._status
@property
def rating(self) -> float:
return self._rating
def is_available(self) -> bool:
return self._status is CabStatus.AVAILABLE
def compare_and_set(self, expected: CabStatus, new: CabStatus) -> bool:
with self._lock:
if self._status is not expected:
return False
self._status = new
return True
def try_claim(self) -> bool:
"""AVAILABLE -> BOOKED atomically. False means someone else got them first."""
return self.compare_and_set(CabStatus.AVAILABLE, CabStatus.BOOKED)
def _force_status(self, new: CabStatus) -> None:
# Only called by Trip while it holds the trip lock and owns this driver.
with self._lock:
self._status = new
def add_rating(self, stars: float) -> None:
if not 1 <= stars <= 5:
raise ValueError("rating must be between 1 and 5")
with self._lock:
self._rating = (self._rating * self._rated_trips + stars) / (self._rated_trips + 1)
self._rated_trips += 1
def __str__(self) -> str:
return f"{self.name} ({self.cab_type.value})"
# ============================================================
# Pricing (Strategy + Decorator), money as Decimal
# ============================================================
class PricingStrategy(ABC):
@abstractmethod
def calculate_fare(self, distance_km: float, duration_min: float) -> Decimal:
...
@dataclass(frozen=True)
class RateCard:
base: Decimal
per_km: Decimal
per_min: Decimal
RATE_CARDS: Dict[CabType, RateCard] = {
CabType.AUTO: RateCard(Decimal("25"), Decimal("8"), Decimal("0.5")),
CabType.MINI: RateCard(Decimal("50"), Decimal("10"), Decimal("1")),
CabType.SEDAN: RateCard(Decimal("80"), Decimal("14"), Decimal("1.5")),
CabType.SUV: RateCard(Decimal("120"), Decimal("18"), Decimal("2")),
CabType.PREMIUM: RateCard(Decimal("150"), Decimal("22"), Decimal("2.5")),
}
class StandardPricing(PricingStrategy):
def __init__(self, cab_type: CabType):
self._card = RATE_CARDS[cab_type]
def calculate_fare(self, distance_km: float, duration_min: float) -> Decimal:
km = Decimal(str(round(distance_km, 3)))
mins = Decimal(str(round(duration_min, 2)))
return to_money(self._card.base + self._card.per_km * km + self._card.per_min * mins)
class SurgePricing(PricingStrategy):
"""Decorator: multiplies whatever the wrapped strategy charges."""
def __init__(self, base: PricingStrategy, multiplier: Decimal):
if multiplier < 1:
raise ValueError("surge multiplier must be >= 1")
self._base = base
self._multiplier = multiplier
def calculate_fare(self, distance_km: float, duration_min: float) -> Decimal:
return to_money(self._base.calculate_fare(distance_km, duration_min) * self._multiplier)
# ============================================================
# Geohash + GeoIndex (in-memory stand-in for Redis GEO)
# ============================================================
_BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
def geohash_encode(lat: float, lng: float, precision: int) -> str:
lat_rng, lng_rng = [-90.0, 90.0], [-180.0, 180.0]
chars, ch, bit, even = [], 0, 0, True
while len(chars) < precision:
rng, val = (lng_rng, lng) if even else (lat_rng, lat)
mid = (rng[0] + rng[1]) / 2
if val >= mid:
ch |= 1 << (4 - bit)
rng[0] = mid
else:
rng[1] = mid
even = not even
bit += 1
if bit == 5:
chars.append(_BASE32[ch])
ch, bit = 0, 0
return "".join(chars)
def geohash_bbox(gh: str) -> Tuple[float, float, float, float]:
"""(min_lat, max_lat, min_lng, max_lng) of a geohash cell."""
lat_rng, lng_rng = [-90.0, 90.0], [-180.0, 180.0]
even = True
for c in gh:
v = _BASE32.index(c)
for b in range(4, -1, -1):
rng = lng_rng if even else lat_rng
mid = (rng[0] + rng[1]) / 2
if (v >> b) & 1:
rng[0] = mid
else:
rng[1] = mid
even = not even
return lat_rng[0], lat_rng[1], lng_rng[0], lng_rng[1]
def geohash_cell_km(precision: int, at_lat: float) -> Tuple[float, float]:
"""(height_km, width_km) of a cell. Precision 5 ~ 4.9x4.9 km, 6 ~ 0.61x1.2 km, 7 ~ 153x153 m."""
bits = 5 * precision
lng_bits, lat_bits = (bits + 1) // 2, bits // 2
height = 180 / (1 << lat_bits) * 111.32
width = 360 / (1 << lng_bits) * 111.32 * math.cos(math.radians(at_lat))
return height, width
def geohash_neighbourhood(gh: str) -> List[str]:
"""The cell itself plus its 8 neighbours (wraps at the antimeridian, clips at the poles)."""
min_lat, max_lat, min_lng, max_lng = geohash_bbox(gh)
h, w = max_lat - min_lat, max_lng - min_lng
c_lat, c_lng = (min_lat + max_lat) / 2, (min_lng + max_lng) / 2
cells = []
for dy in (-1, 0, 1):
lat = c_lat + dy * h
if not -90 < lat < 90:
continue
for dx in (-1, 0, 1):
lng = (c_lng + dx * w + 180) % 360 - 180
cells.append(geohash_encode(lat, lng, len(gh)))
return list(dict.fromkeys(cells))
class GeoIndex:
"""
Mirrors how Redis GEO works: members live in ONE sorted structure ordered by
geohash, and a radius query is a handful of range scans (the 3x3 cells at a
precision whose cell is at least `radius` wide) followed by an exact
haversine filter. Query cost is O(9 log N + M) for M points in those cells.
(Redis uses a skiplist, so inserts are O(log N); this sorted list is O(N)
per insert, which is fine for a simulation.)
It stores driver ids only; availability and cab type are the caller's
concern, just as with a Redis key. Updates carry a timestamp and older
updates are ignored, so out-of-order or replayed GPS events are harmless.
"""
PRECISION = 7
def __init__(self) -> None:
self._lock = threading.Lock()
self._sorted: List[Tuple[str, str]] = [] # (geohash, driver_id)
self._pos: Dict[str, Tuple[str, Location, float]] = {} # driver_id -> (hash, loc, ts)
def upsert(self, driver_id: str, location: Location, ts: float) -> bool:
"""Returns False (and changes nothing) if ts is older than the indexed position."""
new_hash = geohash_encode(location.lat, location.lng, self.PRECISION)
with self._lock:
old = self._pos.get(driver_id)
if old is not None:
if ts < old[2]:
return False
self._remove_entry(old[0], driver_id)
bisect.insort(self._sorted, (new_hash, driver_id))
self._pos[driver_id] = (new_hash, location, ts)
return True
def remove(self, driver_id: str) -> None:
with self._lock:
old = self._pos.pop(driver_id, None)
if old is not None:
self._remove_entry(old[0], driver_id)
def location_of(self, driver_id: str) -> Optional[Location]:
with self._lock:
entry = self._pos.get(driver_id)
return entry[1] if entry else None
def search(self, center: Location, radius_km: float) -> List[Tuple[str, float]]:
"""All (driver_id, distance_km) within radius, nearest first."""
precision = self._precision_for(radius_km, center.lat)
cells = geohash_neighbourhood(geohash_encode(center.lat, center.lng, precision))
hits: List[Tuple[str, float]] = []
with self._lock:
for prefix in cells:
lo = bisect.bisect_left(self._sorted, (prefix, ""))
hi = bisect.bisect_left(self._sorted, (prefix + "~", "")) # '~' sorts after base32
for _, driver_id in self._sorted[lo:hi]:
d = center.distance_to(self._pos[driver_id][1])
if d <= radius_km:
hits.append((driver_id, d))
hits.sort(key=lambda h: (h[1], h[0]))
return hits
def __len__(self) -> int:
return len(self._pos)
def _remove_entry(self, gh: str, driver_id: str) -> None:
i = bisect.bisect_left(self._sorted, (gh, driver_id))
if i < len(self._sorted) and self._sorted[i] == (gh, driver_id):
del self._sorted[i]
def _precision_for(self, radius_km: float, lat: float) -> int:
for p in range(self.PRECISION, 0, -1):
h, w = geohash_cell_km(p, lat)
if h >= radius_km and w >= radius_km:
return p
return 1
# ============================================================
# Kafka simulation: partitioned append-only logs + committed offsets
# ============================================================
@dataclass(frozen=True)
class KafkaMessage:
topic: str
partition: int
offset: int
key: str
value: dict
class KafkaBroker:
"""
Keyed messages go to partition crc32(key) % n, so one driver's events stay
ordered. Consumers poll per (group, topic, partition) offset and commit
AFTER processing (at-least-once): a crash between processing and commit
replays the message, which is why consumers must be idempotent.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._topics: Dict[str, List[List[KafkaMessage]]] = {}
self._committed: Dict[Tuple[str, str, int], int] = {}
def create_topic(self, name: str, partitions: int = 3) -> None:
with self._lock:
self._topics.setdefault(name, [[] for _ in range(partitions)])
def produce(self, topic: str, key: str, value: dict) -> KafkaMessage:
with self._lock:
parts = self._topics.get(topic)
if parts is None:
raise NotFoundError(f"topic {topic} does not exist")
p = zlib.crc32(key.encode()) % len(parts)
msg = KafkaMessage(topic, p, len(parts[p]), key, value)
parts[p].append(msg)
return msg
def poll(self, topic: str, group: str, max_messages: int = 100) -> List[KafkaMessage]:
with self._lock:
out: List[KafkaMessage] = []
for p, log in enumerate(self._topics.get(topic, [])):
start = self._committed.get((group, topic, p), 0)
out.extend(log[start:start + max_messages - len(out)])
if len(out) >= max_messages:
break
return out
def commit(self, group: str, msg: KafkaMessage) -> None:
with self._lock:
key = (group, msg.topic, msg.partition)
self._committed[key] = max(self._committed.get(key, 0), msg.offset + 1)
def topic_size(self, topic: str) -> int:
with self._lock:
return sum(len(log) for log in self._topics.get(topic, []))
# ============================================================
# Zones & surge (geohash-6 cells stand in for H3 hexagons)
# ============================================================
def compute_surge(demand: int, supply: int) -> Decimal:
"""Step function on demand/supply. No demand -> no surge, whatever the supply."""
if demand == 0:
return Decimal("1.0")
if supply == 0:
return Decimal("2.5")
ratio = demand / supply
if ratio > 3.0:
return Decimal("2.0")
if ratio > 2.0:
return Decimal("1.5")
if ratio > 1.5:
return Decimal("1.25")
return Decimal("1.0")
@dataclass
class Zone:
zone_id: str
center: Location
driver_count: int = 0
ride_request_count: int = 0
surge_multiplier: Decimal = Decimal("1.0")
def update_supply_demand(self, driver_count: int, ride_requests: int) -> None:
self.driver_count = driver_count
self.ride_request_count = ride_requests
self.surge_multiplier = compute_surge(ride_requests, driver_count)
def __str__(self) -> str:
return (f"Zone[{self.zone_id}]: {self.driver_count} drivers, "
f"{self.ride_request_count} requests, surge={self.surge_multiplier}x")
class ZoneManager:
"""Point -> zone is O(1): the zone id IS the geohash cell. Zones are created lazily."""
PRECISION = 6 # ~0.6 km x 1.2 km cells
def __init__(self) -> None:
self._lock = threading.Lock()
self._zones: Dict[str, Zone] = {}
def zone_for(self, location: Location) -> Zone:
zone_id = geohash_encode(location.lat, location.lng, self.PRECISION)
with self._lock:
zone = self._zones.get(zone_id)
if zone is None:
min_lat, max_lat, min_lng, max_lng = geohash_bbox(zone_id)
zone = Zone(zone_id, Location((min_lat + max_lat) / 2, (min_lng + max_lng) / 2))
self._zones[zone_id] = zone
return zone
def get_zone(self, zone_id: str) -> Optional[Zone]:
with self._lock:
return self._zones.get(zone_id)
@property
def zones(self) -> List[Zone]:
with self._lock:
return sorted(self._zones.values(), key=lambda z: z.zone_id)
# ============================================================
# Stream processors (Kafka consumers)
# ============================================================
GPS_RAW = "gps.raw.updates"
GPS_ENRICHED = "gps.enriched.locations"
ZONE_COUNTS = "gps.zone.driver_counts"
GPS_DLQ = "gps.dlq"
TRIP_EVENTS = "trip.events"
class GPSLocationStreamProcessor:
"""gps.raw.updates -> validate -> GeoIndex upsert -> enrich with zone -> gps.enriched.locations."""
GROUP = "gps-stream-processor"
def __init__(self, broker: KafkaBroker, geo_index: GeoIndex, zones: ZoneManager):
self._broker = broker
self._geo = geo_index
self._zones = zones
self._lock = threading.Lock() # one consumer instance processes its partitions serially
self.total_processed = 0
def poll(self, max_messages: int = 100) -> int:
with self._lock:
processed = 0
for msg in self._broker.poll(GPS_RAW, self.GROUP, max_messages):
try:
v = msg.value
location = Location(float(v["lat"]), float(v["lng"]))
if self._geo.upsert(v["driver_id"], location, float(v["ts"])):
zone = self._zones.zone_for(location)
self._broker.produce(GPS_ENRICHED, v["driver_id"], {
"driver_id": v["driver_id"], "location": location.to_dict(),
"zone_id": zone.zone_id, "speed_kmh": v.get("speed_kmh", 0),
"heading": v.get("heading", 0), "ts": v["ts"]})
processed += 1
except (KeyError, TypeError, ValueError) as e:
self._broker.produce(GPS_DLQ, msg.key, {"error": repr(e), "original": msg.value})
self._broker.commit(self.GROUP, msg) # commit after handling: at-least-once
self.total_processed += processed
return processed
class ZoneAnalyticsAggregator:
"""
Supply = AVAILABLE drivers whose latest position is in the zone (a driver
who moves is counted once, in their current zone). Demand = ride requests
in the current window. aggregate() closes the window (tumbling).
"""
GROUP = "zone-analytics"
def __init__(self, broker: KafkaBroker, zones: ZoneManager,
is_available: Callable[[str], bool]):
self._broker = broker
self._zones = zones
self._is_available = is_available
self._lock = threading.Lock()
self._driver_zone: Dict[str, str] = {}
self._requests: Dict[str, int] = {}
def record_ride_request(self, pickup: Location) -> None:
zone_id = self._zones.zone_for(pickup).zone_id
with self._lock:
self._requests[zone_id] = self._requests.get(zone_id, 0) + 1
def aggregate(self) -> List[Zone]:
with self._lock:
for msg in self._broker.poll(GPS_ENRICHED, self.GROUP, max_messages=10_000):
self._driver_zone[msg.value["driver_id"]] = msg.value["zone_id"]
self._broker.commit(self.GROUP, msg)
supply: Dict[str, int] = {}
for driver_id, zone_id in self._driver_zone.items():
if self._is_available(driver_id):
supply[zone_id] = supply.get(zone_id, 0) + 1
for zone in self._zones.zones:
zone.update_supply_demand(supply.get(zone.zone_id, 0),
self._requests.get(zone.zone_id, 0))
self._broker.produce(ZONE_COUNTS, zone.zone_id, {
"zone_id": zone.zone_id, "driver_count": zone.driver_count,
"ride_requests": zone.ride_request_count,
"surge_multiplier": str(zone.surge_multiplier)})
self._requests.clear()
return self._zones.zones
# ============================================================
# Driver matching: strategies RANK candidates; the service CLAIMS
# ============================================================
class DriverMatchingStrategy(ABC):
"""Pure ranking: given eligible (driver, distance_km) pairs, return them best first."""
@abstractmethod
def rank(self, pickup: Location, candidates: List[Tuple[Driver, float]]) -> List[Driver]:
...
class NearestDriverMatching(DriverMatchingStrategy):
def rank(self, pickup: Location, candidates: List[Tuple[Driver, float]]) -> List[Driver]:
return [d for d, _ in sorted(candidates, key=lambda c: (c[1], c[0].driver_id))]
class HighestRatedDriverMatching(DriverMatchingStrategy):
"""Best rating inside the search radius; distance breaks ties."""
def rank(self, pickup: Location, candidates: List[Tuple[Driver, float]]) -> List[Driver]:
return [d for d, _ in sorted(candidates, key=lambda c: (-c[0].rating, c[1]))]
# ============================================================
# Trip: the state machine. Owns its driver's status while active.
# ============================================================
_TRANSITIONS: Dict[TripStatus, Set[TripStatus]] = {
TripStatus.REQUESTED: {TripStatus.ACCEPTED, TripStatus.CANCELLED},
TripStatus.ACCEPTED: {TripStatus.DRIVER_ARRIVED, TripStatus.CANCELLED},
TripStatus.DRIVER_ARRIVED: {TripStatus.STARTED, TripStatus.CANCELLED},
TripStatus.STARTED: {TripStatus.COMPLETED},
TripStatus.COMPLETED: set(),
TripStatus.CANCELLED: set(),
}
TERMINAL = {TripStatus.COMPLETED, TripStatus.CANCELLED}
@dataclass
class Trip:
trip_id: str
rider: Rider
driver: Driver
pickup: Location
dropoff: Location
cab_type: CabType
fare: Decimal
surge_multiplier: Decimal
status: TripStatus = TripStatus.REQUESTED
cancel_reason: Optional[str] = None
declined_by: Set[str] = field(default_factory=set)
lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
# Callers hold self.lock for every method below.
def _move(self, target: TripStatus) -> None:
if target not in _TRANSITIONS[self.status]:
raise InvalidTransitionError(f"{self.trip_id}: {self.status.name} -> {target.name}")
self.status = target
def accept(self) -> None:
self._move(TripStatus.ACCEPTED)
def arrive(self) -> None:
self._move(TripStatus.DRIVER_ARRIVED)
def start(self) -> None:
self._move(TripStatus.STARTED)
self.driver._force_status(CabStatus.ON_TRIP)
def complete(self) -> Decimal:
self._move(TripStatus.COMPLETED)
self.driver._force_status(CabStatus.AVAILABLE)
return self.fare
def cancel(self, reason: str) -> None:
self._move(TripStatus.CANCELLED)
self.cancel_reason = reason
self.driver._force_status(CabStatus.AVAILABLE)
def reassign(self, new_driver: Driver) -> None:
"""Offer declined: the old driver is freed, a freshly claimed one takes the offer."""
if self.status is not TripStatus.REQUESTED:
raise InvalidTransitionError(f"{self.trip_id}: can only reassign a pending offer")
self.declined_by.add(self.driver.driver_id)
self.driver._force_status(CabStatus.AVAILABLE)
self.driver = new_driver
def __str__(self) -> str:
return f"Trip[{self.trip_id}] {self.rider.name} -> {self.driver.name} {self.status.name} ({self.fare})"
# ============================================================
# Facade
# ============================================================
class CabBookingService:
SEARCH_RADII_KM = (2.0, 5.0, 10.0) # progressive expansion
AVG_SPEED_KMH = 30.0
def __init__(self, matching: Optional[DriverMatchingStrategy] = None,
broker: Optional[KafkaBroker] = None):
self._lock = threading.Lock() # guards the registries below
self._riders: Dict[str, Rider] = {}
self._drivers: Dict[str, Driver] = {}
self._trips: Dict[str, Trip] = {}
self._active_trip_by_rider: Dict[str, Optional[str]] = {}
self._ids = itertools.count(1)
self._matching = matching or NearestDriverMatching()
self.geo_index = GeoIndex()
self.zone_manager = ZoneManager()
self.broker = broker
self._gps: Optional[GPSLocationStreamProcessor] = None
self.zone_analytics: Optional[ZoneAnalyticsAggregator] = None
if broker is not None:
for topic, parts in ((GPS_RAW, 5), (GPS_ENRICHED, 5), (ZONE_COUNTS, 3),
(GPS_DLQ, 1), (TRIP_EVENTS, 3)):
broker.create_topic(topic, parts)
self._gps = GPSLocationStreamProcessor(broker, self.geo_index, self.zone_manager)
self.zone_analytics = ZoneAnalyticsAggregator(
broker, self.zone_manager,
lambda d_id: (d := self._drivers.get(d_id)) is not None and d.is_available())
# ---- registration / lookup ----
def _next_id(self, prefix: str) -> str:
return f"{prefix}-{next(self._ids):04d}"
def register_rider(self, name: str, phone: str) -> Rider:
with self._lock:
rider = Rider(self._next_id("R"), name, phone)
self._riders[rider.rider_id] = rider
return rider
def register_driver(self, name: str, phone: str, license_number: str,
cab_type: CabType) -> Driver:
with self._lock:
driver = Driver(self._next_id("D"), name, phone, license_number, cab_type)
self._drivers[driver.driver_id] = driver
return driver
def get_trip(self, trip_id: str) -> Trip:
trip = self._trips.get(trip_id)
if trip is None:
raise NotFoundError(f"trip {trip_id}")
return trip
def _get_driver(self, driver_id: str) -> Driver:
driver = self._drivers.get(driver_id)
if driver is None:
raise NotFoundError(f"driver {driver_id}")
return driver
def set_matching_strategy(self, strategy: DriverMatchingStrategy) -> None:
self._matching = strategy
# ---- driver presence ----
def update_driver_location(self, driver_id: str, location: Location, ts: float,
speed_kmh: float = 0.0, heading: int = 0) -> None:
"""With a broker: publish to Kafka and drain the consumer (synchronously, for the sim)."""
self._get_driver(driver_id)
if self.broker is None or self._gps is None:
self.geo_index.upsert(driver_id, location, ts)
return
self.broker.produce(GPS_RAW, driver_id, {
"driver_id": driver_id, "lat": location.lat, "lng": location.lng,
"speed_kmh": speed_kmh, "heading": heading, "ts": ts})
self._gps.poll()
def go_offline(self, driver_id: str) -> bool:
"""Only an AVAILABLE driver can go offline; a booked driver must finish first."""
return self._get_driver(driver_id).compare_and_set(CabStatus.AVAILABLE, CabStatus.OFFLINE)
def go_online(self, driver_id: str) -> bool:
return self._get_driver(driver_id).compare_and_set(CabStatus.OFFLINE, CabStatus.AVAILABLE)
# ---- matching ----
def _claim_driver(self, pickup: Location, cab_type: CabType,
exclude: Iterable[str] = ()) -> Optional[Driver]:
"""
Search -> rank -> try_claim in rank order. The search result is a stale
snapshot, so a candidate may have been taken by a concurrent request;
try_claim is the only authority and a failed claim just means "next".
"""
excluded, tried = set(exclude), set()
for radius in self.SEARCH_RADII_KM:
candidates = []
for driver_id, dist in self.geo_index.search(pickup, radius):
driver = self._drivers.get(driver_id)
if (driver is None or driver_id in excluded or driver_id in tried
or driver.cab_type is not cab_type or not driver.is_available()):
continue
candidates.append((driver, dist))
for driver in self._matching.rank(pickup, candidates):
tried.add(driver.driver_id)
if driver.try_claim():
return driver
return None
# ---- ride lifecycle ----
def request_ride(self, rider_id: str, pickup: Location, dropoff: Location,
cab_type: CabType = CabType.MINI) -> Trip:
"""Claims a driver and creates a REQUESTED trip (the offer). Raises if nothing claimable."""
with self._lock:
rider = self._riders.get(rider_id)
if rider is None:
raise NotFoundError(f"rider {rider_id}")
if rider_id in self._active_trip_by_rider:
raise RiderBusyError(f"{rider.name} already has an active trip")
self._active_trip_by_rider[rider_id] = None # reserve the rider before matching
try:
if self.zone_analytics:
self.zone_analytics.record_ride_request(pickup)
driver = self._claim_driver(pickup, cab_type)
if driver is None:
raise NoDriverAvailableError(f"no {cab_type.value} within {self.SEARCH_RADII_KM[-1]} km")
surge = self.zone_manager.zone_for(pickup).surge_multiplier
distance = pickup.distance_to(dropoff)
fare = SurgePricing(StandardPricing(cab_type), surge).calculate_fare(
distance, distance / self.AVG_SPEED_KMH * 60)
with self._lock:
trip = Trip(self._next_id("T"), rider, driver, pickup, dropoff, cab_type, fare, surge)
self._trips[trip.trip_id] = trip
self._active_trip_by_rider[rider_id] = trip.trip_id
except BaseException:
with self._lock:
self._active_trip_by_rider.pop(rider_id, None)
raise
self._publish(trip)
return trip
def accept_trip(self, trip_id: str) -> Trip:
return self._apply(trip_id, Trip.accept)
def decline_trip(self, trip_id: str) -> Trip:
"""Driver declines (or the offer times out): re-match, never offering the same driver twice."""
trip = self.get_trip(trip_id)
with trip.lock:
if trip.status is not TripStatus.REQUESTED:
raise InvalidTransitionError(f"{trip_id}: no pending offer to decline")
exclude = trip.declined_by | {trip.driver.driver_id}
replacement = self._claim_driver(trip.pickup, trip.cab_type, exclude)
if replacement is not None:
trip.reassign(replacement)
else:
trip.cancel("no driver accepted")
self._release_rider(trip)
self._publish(trip)
return trip
def driver_arrived(self, trip_id: str) -> Trip:
return self._apply(trip_id, Trip.arrive)
def start_trip(self, trip_id: str) -> Trip:
return self._apply(trip_id, Trip.start)
def complete_trip(self, trip_id: str) -> Decimal:
trip = self._apply(trip_id, Trip.complete)
return trip.fare
def cancel_trip(self, trip_id: str, reason: str = "rider cancelled") -> Trip:
return self._apply(trip_id, lambda t: t.cancel(reason))
def rate_driver(self, trip_id: str, stars: float) -> None:
trip = self.get_trip(trip_id)
if trip.status is not TripStatus.COMPLETED:
raise InvalidTransitionError("can only rate a completed trip")
trip.driver.add_rating(stars)
def _apply(self, trip_id: str, action: Callable[[Trip], object]) -> Trip:
trip = self.get_trip(trip_id)
with trip.lock:
action(trip)
if trip.status in TERMINAL:
self._release_rider(trip)
self._publish(trip)
return trip
def _release_rider(self, trip: Trip) -> None:
with self._lock:
if self._active_trip_by_rider.get(trip.rider.rider_id) == trip.trip_id:
del self._active_trip_by_rider[trip.rider.rider_id]
def _publish(self, trip: Trip) -> None:
if self.broker is not None:
self.broker.produce(TRIP_EVENTS, trip.trip_id, {
"trip_id": trip.trip_id, "status": trip.status.name,
"rider_id": trip.rider.rider_id, "driver_id": trip.driver.driver_id,
"fare": str(trip.fare), "surge_multiplier": str(trip.surge_multiplier)})
# ============================================================
# Demo (deterministic)
# ============================================================
def demo() -> None:
rng = random.Random(42)
center = Location(19.0760, 72.8777) # Mumbai
svc = CabBookingService(broker=KafkaBroker())
print("=== Cab Booking Service ===")
alice = svc.register_rider("Alice", "9876543210")
cab_types = [CabType.MINI, CabType.SEDAN, CabType.SUV, CabType.PREMIUM]
for i in range(20):
d = svc.register_driver(f"Driver-{i + 1}", f"9999{i:02d}", f"LIC{i:04d}", cab_types[i % 4])
loc = Location(center.lat + rng.uniform(-0.03, 0.03), center.lng + rng.uniform(-0.03, 0.03))
svc.update_driver_location(d.driver_id, loc, ts=float(i), speed_kmh=rng.uniform(0, 60))
print(f"Indexed drivers: {len(svc.geo_index)}, GPS events published: "
f"{svc.broker.topic_size(GPS_RAW)}")
# Happy path with the full state machine.
pickup, dropoff = Location(19.0780, 72.8780), Location(19.1000, 72.9000)
trip = svc.request_ride(alice.rider_id, pickup, dropoff, CabType.SEDAN)
print(f"Requested: {trip}")
svc.decline_trip(trip.trip_id)
print(f"After first driver declined: {trip} (declined_by={sorted(trip.declined_by)})")
for step in (svc.accept_trip, svc.driver_arrived, svc.start_trip):
step(trip.trip_id)
print(f"Completed, fare charged: {svc.complete_trip(trip.trip_id)}")
svc.rate_driver(trip.trip_id, 4)
# Invalid transition is rejected.
try:
svc.cancel_trip(trip.trip_id)
except InvalidTransitionError as e:
print(f"Rejected: {e}")
# Surge: many requests in one zone, then aggregate the window.
for i in range(6):
r = svc.register_rider(f"Rider-{i}", f"8000{i}")
try:
t = svc.request_ride(r.rider_id, pickup, dropoff, CabType.SUV)
svc.cancel_trip(t.trip_id)
except NoDriverAvailableError:
pass
svc.zone_analytics.aggregate()
zone = svc.zone_manager.zone_for(pickup)
print(f"Pickup {zone}")
surged = svc.request_ride(alice.rider_id, pickup, dropoff, CabType.MINI)
print(f"Surged trip: {surged}")
svc.cancel_trip(surged.trip_id)
# Concurrency: 10 riders race for the only PREMIUM driver near a remote pickup.
lone = svc.register_driver("Lone", "7000", "LIC-P", CabType.PREMIUM)
remote = Location(28.6139, 77.2090) # Delhi
svc.update_driver_location(lone.driver_id, remote, ts=100.0)
riders = [svc.register_rider(f"Racer-{i}", f"6000{i}") for i in range(10)]
wins: List[Trip] = []
barrier = threading.Barrier(len(riders))
def race(r: Rider) -> None:
barrier.wait()
try:
wins.append(svc.request_ride(r.rider_id, remote, dropoff, CabType.PREMIUM))
except NoDriverAvailableError:
pass
threads = [threading.Thread(target=race, args=(r,)) for r in riders]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Concurrent requests for 1 driver -> {len(wins)} trip(s) created")
print(f"Kafka trip events: {svc.broker.topic_size(TRIP_EVENTS)}")
if __name__ == "__main__":
demo()