Module 17: Ride-Sharing
Why this matters
This module continues module 16's lesson that concurrency-safety is
core, not an extension — but makes a different lock-granularity call,
on purpose. Movie seats partition cleanly: each show's seats are
independent, so module 16 gave every Show its own lock. A driver
fleet doesn't partition that way by default — every ride request in
the system contends over the same pool of drivers, so this module
uses a single lock over the whole fleet. Seeing both choices, each
justified by the actual shape of the shared resource, is the point:
lock granularity is a design decision made from the problem, not a
rule ("always use one big lock" or "always shard") applied by habit.
Requirements
Functional: a rider requests a ride from a pickup location to a destination; the system matches the request to the best available driver (nearest, by default) and marks that driver unavailable; on completion, fare is computed from trip distance and the driver becomes available again at the destination; a ride can be cancelled while still requested or accepted, freeing its driver if one was assigned.
Non-functional: driver matching must be correct under many
concurrent ride requests — two riders must never end up matched to the
same driver; the matching rule (nearest, highest-rated, cheapest ETA)
must be swappable without touching RideManager's core flow (DIP,
module 04; Strategy, module 08).
Assumptions stated up front (module 11, step 1): a driver serves
exactly one ride at a time — no ride pooling; fare is a simple
distance × rate calculation, no surge pricing or time component; ride
status is a plain enum with guard checks, not a full State-pattern
hierarchy — module 13 already made this exact call for BookStatus,
and the same reasoning applies: few, simple transitions don't justify
the ceremony.
Entities and relationships
Applying module 11's steps 3–4: RideManager o-- Driver (aggregation —
manages the fleet, many drivers); RideManager --> Ride (creates, one
per successful requestRide call); Ride --> Driver, Ride --> Rider
(association); Driver --> Location, Ride --> Location for pickup and
destination (association); NearestDriverStrategy ..|> DriverMatchingStrategy (realizes — Strategy, module 08, injected per
call — DIP, module 04).
Class diagram
┌──────────────────┐ ┌───────────────┐
│ RideManager │ o── │ Driver │
├──────────────────┤ ├───────────────┤
│ - drivers │ │ - location │
│ - nextId │ │ - isAvailable │
├──────────────────┤ └───────────────┘
│ + requestRide() │
│ + completeRide() │
│ + cancelRide() │
└──────────────────┘
RideManager --> Ride (creates one per successful requestRide() call)
Ride --> Driver, Ride --> Rider (association)
Driver --> Location, Ride --> Location (pickup/destination) (association)
┌───────────────────────┐
│ Ride │
├───────────────────────┤
│ - rider, driver │
│ - pickup, destination │
│ - status │
│ - fare │
└───────────────────────┘
┌────────┐ ┌────────────────┐
│ Rider │ │ Location │
├────────┤ ├────────────────┤
│ - name │ │ - x, y │
└────────┘ ├────────────────┤
│ + distanceTo() │
└────────────────┘
DriverMatchingStrategy (interface) <── NearestDriverStrategy (Strategy, module 08)
Implementation
Notice the lock in request_ride/RequestRide wraps only "find an
available driver, mark it unavailable" — not the Ride object's
construction afterward. That data isn't shared until the method
returns it to the caller, so locking it too would only add contention
without adding safety (module 10: keep critical sections minimal).
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum, auto
@dataclass(frozen=True) # value object, module 03
class Location:
x: float
y: float
def distance_to(self, other: "Location") -> float:
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
class Driver:
def __init__(self, driver_id: str, name: str, location: Location):
self.driver_id = driver_id
self.name = name
self.location = location
self.is_available = True
class Rider:
def __init__(self, rider_id: str, name: str):
self.rider_id = rider_id
self.name = name
class DriverMatchingStrategy(ABC): # Strategy, module 08
@abstractmethod
def match(self, available_drivers: list[Driver], pickup: Location) -> "Driver | None": ...
class NearestDriverStrategy(DriverMatchingStrategy):
def match(self, available_drivers, pickup):
if not available_drivers:
return None
return min(available_drivers, key=lambda d: d.location.distance_to(pickup))
class RideStatus(Enum):
REQUESTED = auto()
ACCEPTED = auto()
IN_PROGRESS = auto()
COMPLETED = auto()
CANCELLED = auto()
class Ride:
def __init__(self, ride_id: str, rider: Rider, pickup: Location, destination: Location):
self.ride_id = ride_id
self.rider = rider
self.pickup = pickup
self.destination = destination
self.driver: "Driver | None" = None
self.status = RideStatus.REQUESTED
self.fare: "float | None" = None
class RideManager:
RATE_PER_UNIT_DISTANCE = 5.0
def __init__(self, drivers: list[Driver]):
self.drivers = drivers
self._next_id = 1
self._lock = threading.Lock() # one lock for the whole fleet — drivers are a genuinely shared pool
def request_ride(self, rider: Rider, pickup: Location, destination: Location,
strategy: DriverMatchingStrategy) -> "Ride | None":
with self._lock: # critical section: check-then-assign over the shared driver pool
available = [d for d in self.drivers if d.is_available]
matched = strategy.match(available, pickup)
if matched is None:
return None
matched.is_available = False
ride = Ride(f"R{self._next_id}", rider, pickup, destination)
self._next_id += 1
ride.driver = matched
ride.status = RideStatus.ACCEPTED
return ride
def complete_ride(self, ride: Ride) -> float:
if ride.status != RideStatus.ACCEPTED and ride.status != RideStatus.IN_PROGRESS:
raise ValueError(f"Cannot complete a ride in status {ride.status}")
distance = ride.pickup.distance_to(ride.destination)
ride.fare = round(distance * self.RATE_PER_UNIT_DISTANCE, 2)
ride.status = RideStatus.COMPLETED
with self._lock:
ride.driver.is_available = True
ride.driver.location = ride.destination
return ride.fare
def cancel_ride(self, ride: Ride):
if ride.status not in (RideStatus.REQUESTED, RideStatus.ACCEPTED):
raise ValueError(f"Cannot cancel a ride in status {ride.status}")
ride.status = RideStatus.CANCELLED
if ride.driver is not None:
with self._lock:
ride.driver.is_available = True
# usage
drivers = [Driver("D1", "Sam", Location(0, 0)), Driver("D2", "Ana", Location(10, 10))]
manager = RideManager(drivers)
rider = Rider("U1", "Priya")
ride = manager.request_ride(rider, Location(1, 1), Location(5, 5), NearestDriverStrategy())
print(f"Matched driver: {ride.driver.name if ride else None}") # Sam — closer to pickup (1,1)
fare = manager.complete_ride(ride)
print(f"Fare: {fare}, driver available again: {drivers[0].is_available}")
Tradeoffs and extensions
- One lock for the whole fleet, not one per driver or per ride — and this is a genuinely different call than module 16's, not a regression from it. Movie seats partition cleanly by show; the driver pool here doesn't partition by anything in this model, so every ride request contends over the same resource. Lock granularity follows the shape of the shared state, not a fixed rule.
- Named extension: partition by region. A real system assigns
drivers to geographic regions and locks per-region — once region
exists as a concept in the model, that's exactly module 16's
per-
Showpattern applied here (exercise 5). - The critical section covers only the driver-pool check-then-assign,
not
Rideconstruction or any notification/I-O that would follow in a real system. Locking more than the actually-shared state adds contention without adding safety. RideStatusis a plain enum with guards, following module 13'sBookStatusprecedent rather than a full State-pattern hierarchy — named consistently with the same reasoning: few, simple transitions don't justify the ceremony a full class-per-state design would add.
Hands-on exercises
1. Add a highest-rated-driver strategy
Give Driver a rating field and implement
HighestRatedDriverStrategy, picking the highest-rated available
driver instead of the nearest. Confirm it swaps into RideManager
with zero other changes.
2. Start a ride
Implement start_ride(ride)/StartRide(ride), transitioning
ACCEPTED → IN_PROGRESS with a guard — confirm it rejects a Ride
still in REQUESTED or already COMPLETED.
3. ETA-aware matching
Give Driver a speed field and implement a matching strategy that
picks the driver with the shortest estimated time to pickup
(distance ÷ speed) rather than raw distance.
4. Write your own concurrency stress test
Start many rider threads requesting rides concurrently against a
driver pool smaller than the rider count. Assert exactly
min(riders, drivers) rides are matched and every matched driver ID is
unique — the same shape of test used to verify this module's own
implementation.
5. Partition by region
Give Driver a region field and RideManager one lock per region
instead of one global lock. Confirm (with a targeted test or by
reasoning through it) that ride requests in different regions no
longer block each other.
Independent challenge
No code given.
Task: Add driver ratings. After a ride completes, the rider can
call rate_ride(ride, stars)/RateRide(ride, stars) with a 1–5 rating.
Track each driver's average rating and expose it as
Driver.average_rating/Driver.AverageRating, then update exercise
1's HighestRatedDriverStrategy to actually use it. Constraint:
no two independently-updated numbers that could drift — the same
single-source-of-truth discipline Ledger used in module 15. Don't
store a running sum and a separately-maintained average that a write
path could forget to keep in sync; derive the average from what's
actually stored.
Hint
Two valid single-source-of-truth designs: (a) store every individual
rating in a list per driver and compute the average on read
(sum(ratings) / len(ratings)), or (b) store a running (total, count) pair updated together in exactly one method, with average
computed as total / count on read. What to avoid either way: a
cached average_rating field that gets written directly by more than
one code path — that's exactly the two-sources-of-truth shape module
15's Ledger section warned about, just with ratings instead of
balances.
Common mistakes & troubleshooting
- Splitting "find an available driver" and "mark it unavailable" into two separate calls instead of one locked method. This reopens the exact check-then-act race the lock exists to prevent — the single most common bug in this exact classic problem.
- Using a lock per
Rideinstead of a shared lock over the driver pool. A per-ride lock protects nothing, since the actual race is between different ride requests contending over the same drivers, not within a single ride. - Holding the lock for the entire
request_ride/RequestRidecall, includingRideconstruction or any notification step a real system would add. This needlessly widens the critical section and hurts throughput for no safety benefit — lock only the shared driver-pool mutation. - Forgetting to make a driver available again (and update their location) when a ride completes or is cancelled. A driver stuck permanently unavailable after their first ride silently shrinks the usable fleet.
- Letting
complete_ride/CompleteRideorcancel_ride/CancelRiderun against a ride in the wrong status without a guard check — completing an already-cancelled ride, for instance — silently corrupts ride state instead of failing clearly.
Checkpoint quiz
- Why does
RideManageruse one lock for the whole driver pool instead of a lock per driver or per ride? - Contrast this module's lock granularity with module 16's Movie
Booking — why is a single lock correct here but per-
Showlocks were correct there? - Why isn't
Rideconstruction inside the locked section ofrequest_ride/RequestRide? - Why is
RideStatusa plain enum with guards rather than a full State-pattern hierarchy, and where has this track made that same call before? - Per the independent challenge, what must a driver's rating computation avoid to prevent drift?
Answers
- Because every ride request in the system contends over the same
shared pool of drivers — a per-driver or per-ride lock wouldn't
protect the actual shared state being read and mutated (
is_availableacross the whole list). - Movie seats partition cleanly by show — each show's seats are independent state, so per-show locks add no unnecessary contention. The driver pool here doesn't partition by anything in this model; every request reads and mutates the same shared list, so a single lock is the correct granularity for the shape of this shared state, not a rule applied regardless of shape.
- Because the
Rideobject isn't shared state until it's returned to the caller — locking its construction would add contention without protecting anything, violating module 10's "keep critical sections minimal" guidance. RideStatushas few, simple transitions with little behavioral difference per status — the same reasoning module 13 used forBookStatus(Library) rather than the Vending Machine's full State pattern: a class-per-state hierarchy would be ceremony without payoff here too.- Storing a cached average that more than one code path writes
directly — any design where the average could be updated
inconsistently with the underlying ratings. The fix is deriving the
average from a single authoritative source (either every stored
rating, or a running total/count pair updated together in exactly
one place), the same discipline module 15's
Ledgerused for balances.
Interview questions
- "How do you prevent two riders from being matched to the same
driver at the same time?"
RideManagerholds a single lock over the driver pool; matching ("find an available driver" + "mark it unavailable") happens inside one locked method, so the check-then-act sequence can't be interleaved by two concurrent requests — the same discipline asLevel.find_and_assignin module 12, applied to a person instead of a parking spot. - "Why not lock per-driver instead of one lock for the whole fleet?" The race isn't within one driver's state — it's between multiple ride requests competing to be the one that claims some available driver from the pool. A per-driver lock wouldn't prevent two requests from both reading the pool's availability list inconsistently; the pool itself is the shared state that needs protecting.
- "How would you extend driver matching to consider more than
distance?"
DriverMatchingStrategyis already an injected interface (Strategy, module 08) — a rating-aware or ETA-aware matcher is a new class implementing the same interface, with zero changes toRideManager. - "How would you scale this to a much larger fleet across many
cities?"
Partition drivers by geographic region and give each region its own
lock, exactly like module 16's per-
Showlocking — ride requests in different regions then never contend with each other, and a real system would pair this with a spatial index (e.g., a geohash or quadtree) so "find nearby available drivers" doesn't mean scanning every driver in the region. - "How would you add driver ratings without introducing a second
source of truth that can drift?"
Never store a cached average that gets written by more than one code
path. Store either every individual rating or a running
(total, count)pair updated together in exactly one method, and always derive the average on read — the same single-source-of-truth discipline module 15'sLedgerused for balances.
Further reading & sources
- Refactoring.Guru: Strategy pattern - revisit module 08's pattern, applied here to driver matching exactly as it was to elevator dispatch in module 12.
- Python
threading.Lock/ Microsoft Learn:lockstatement - revisit module 10's primitives; this module's single-lock-over-a-shared-pool choice, contrasted with module 16's per-resource locks. - Geohash (Wikipedia) - the spatial-indexing technique real ride-sharing systems use for "find nearby available drivers" at a scale where scanning every driver stops being feasible — relevant background for the scaling/region-partitioning extension.
Next
18-lru-lfu-cache-and-rate-limiter — full guided solutions for an LRU cache, an LFU cache, and a rate limiter.