Design a Real-Time Dispatch Platform
A proctor is due at a test center in forty minutes. Three qualified people are on shift nearby. One has not sent a location in two minutes. One already has a job that fills the win
Design a Real-Time Dispatch Platform
Advanced System Design & Software Architecture · Lesson 59 · Real-World System Design Case Studies
Lesson Overview
A proctor is due at a test center in forty minutes. Three qualified people are on shift nearby. One has not sent a location in two minutes. One already has a job that fills the window. One is slightly farther away and has had no work all morning. TechLearningPro Dispatch has to offer the job to a worker who can actually arrive, without handing the same worker two overlapping jobs, and without always picking the person who happens to be closest.
This is a dispatch system: jobs with skills, time windows, and service areas, assigned to workers who publish locations. It is not the ride-booking product. A ride-booking design centers a passenger, a pickup, a trip state machine, and a fare. Those concerns belong to that case study. Dispatch centers a work item, a feasible set, an offer lease, an ETA used as an input, and a fairness rule so supply is not exhausted on whoever is nearest. The same platform assigns exam-proctor visits, classroom equipment setups, and course-kit deliveries. The domain nouns change. The assignment loop does not.
Locations are updates, not source-of-truth business records. A fix every few seconds from fifty thousand active workers is a high-rate stream. The assignment decision is a low-rate transactional update: a job moves from OPEN to OFFERED to exactly one worker, under a constraint that survives two dispatchers racing. Geospatial indexing makes the candidate query cheap. It does not make a stale GPS reading true. Workers whose last fix is older than the freshness window are ineligible for on-demand offers and remain visible to the batch planner with a penalty.
ETA comes in two steps. A cheap distance filter, using H3 cell neighborhoods or a great-circle distance, drops obviously impossible candidates. The survivors get a road ETA from a routing engine. This lesson uses OSRM's documented HTTP API, GET /route/v1/{profile}/{coordinates}, when a self-hosted router is configured, and falls back to the distance filter with a stated uncertainty when the router is down. It does not invent a maps vendor's private SDK. H3, the open hexagonal index, is how we bucket workers and demand. Redis GEOADD and GEOSEARCH are an acceptable hot index when we want radius queries without operating H3 ourselves. The durable job and the offer are PostgreSQL.
Module 6 will ask what happens when two jobs want one worker, when locations freeze, and how you rebalance idle supply without pretending to be a ride-hailing marketplace. System Design Interview Framework rewards that distinction. If the interviewer wanted a passenger app, say what you left out and why.
Learning Objectives
By the end of this lesson, you should be able to:
- Separate dispatch (jobs, skills, windows, offers) from ride booking (trips, passengers, fares).
- Write requirements for eligibility, assignment, expiry, and rebalancing suggestions.
- Estimate location-update rate versus assignment rate, and size the index for the update rate.
- Choose a geo index (H3 cells or Redis GEO) and a freshness rule for stale fixes.
- Specify an offer lease so a worker and a job are each assigned at most once.
- Use a cheap distance filter and a routed ETA, with a defined fallback when routing is down.
- Add a fairness term so the nearest worker does not absorb every job in a cell.
- Describe batch planning for scheduled jobs versus immediate offers for on-demand jobs.
- Handle offer timeout, worker decline, and a worker who goes offline after accepting.
- Secure location history and restrict who can see a worker's trace.
- Review a design that assigns from a stale index with no lease.
- Defend the design in a Module 6 interview with capacity numbers and failure modes.
Prerequisites
- Lesson 24 and lesson 25: the location stream is the high-rate path; the job transaction is the correctness path. Do not size them as one number.
- Lesson 28: indexes that match the query. A location table without a cell or time predicate will not survive the write rate if you also query it on every assignment.
- Lesson 33: location events are at least once. The latest fix per worker wins by timestamp, not by arrival order alone.
- Lesson 36: a lease, not a long-held lock across a human's accept click. Offer expiry is the lease timeout.
- Lesson 41: the routing engine is a dependency with a timeout and a fallback. Assignment does not fail closed into "no one is available" solely because OSRM timed out, unless product explicitly prefers that.
- Lesson 44: workers, dispatchers, and customers authenticate as different roles. A customer does not read raw GPS history.
- Awareness that Design a Ride Booking Platform owns trip lifecycle and pricing. This lesson does not repeat it.
Detailed Core Concepts
Jobs, workers, and offers
A job has a type, required skill tags, a location, a time window (not_before, due_by), a service duration estimate, a priority, and a state. A worker has skills, a shift window, a capacity (how many overlapping jobs they may hold; often one), a home base, and a stream of location fixes. An offer binds one job to one worker until they accept, decline, or the offer expires.
States for a job:
| State | Meaning |
|---|---|
OPEN | Eligible for assignment. No live offer |
OFFERED | One worker holds an unexpired offer |
ACCEPTED | Worker committed. The time window is reserved on their schedule |
IN_PROGRESS | Worker started. Location is for ETA-to-complete, not for reassignment |
DONE | Terminal success |
CANCELLED | Terminal. Releases the worker's capacity |
UNASSIGNED_ALERT | Window is at risk and no eligible worker exists. A human dispatcher sees it |
There is no passenger on board, no fare meter, and no "driver arrived" as the core of the model. Kit delivery can add a proof-of-delivery artifact later. Proctoring can add a check-in code. Those are attachments. The assignment loop stays the same.
Functional requirements
| Id | Requirement |
|---|---|
| FR-1 | Ingest a job with skills, location, window, duration, and priority |
| FR-2 | Ingest worker location fixes with a device timestamp and a server receive time |
| FR-3 | On-demand assignment returns an offer or a clear "no eligible worker" within the decision SLO |
| FR-4 | A worker has at most one live offer and does not receive overlapping accepted jobs beyond capacity |
| FR-5 | A job has at most one live offer. A second dispatcher cannot offer it to someone else while the first offer stands |
| FR-6 | Offers expire. Expiry returns the job to OPEN and the worker to eligible |
| FR-7 | Decline and timeout try the next candidate, skipping workers already tried for this job until a cooldown |
| FR-8 | Batch planner runs on a schedule for jobs whose window is later, producing offers or recommended assignments a human confirms |
| FR-9 | Rebalancing suggests idle workers move toward undersupplied cells. It does not create a fake job that occupies them unless they accept a reposition task |
| FR-10 | Dispatchers see job state, offer history, and a coarsened worker location. Customers see job status and an ETA band, not a raw trace |
| FR-11 | Stale locations are ineligible for on-demand offers |
Non-functional requirements
| Quality | Target |
|---|---|
| Decision latency | On-demand offer p99 under 500 ms excluding human accept time, with routing under a 150 ms budget |
| Freshness | Fixes newer than 30 seconds count as fresh for on-demand. Older than 30 seconds are stale. Older than 5 minutes drop out of the hot index |
| Assignment correctness | At most one OFFERED or ACCEPTED live binding per job. Worker capacity never exceeded by accepted jobs |
| Location ingest | Sustain 20,000 fixes per second onto the hot index without blocking the assignment transaction |
| Availability | 99.9% for job create and accept. Degraded mode: distance-filter ETA when routing is down, surfaced as lower confidence |
| Fairness | Within an equal skill and ETA band, the worker with fewer completed jobs today is preferred. The band and the weights are configuration |
| Privacy | Location history retained 7 days for disputes, then deleted. Access is role-gated and audited |
| Safety of fallback | A missing route does not assign a worker on the far side of a river solely because the straight line looked short, when a prior cell-pair penalty is known. If no penalty is known, widen the ETA band shown to the customer |
Eligibility before ranking
Hard filters run first. A worker who fails any one is absent from the candidate list:
- Skill tags cover the job's required tags.
- Shift covers the window plus travel.
- Accepted jobs plus this duration do not exceed capacity or overlap the window.
- Location fix is fresh for on-demand, or explicitly allowed and penalized for batch.
- Worker is not
OFFEREDon a different job. - Service area contains the job, or the worker has opted into travel and the ETA still meets
due_by. - Worker was not already offered this job in the last cooldown (for example 10 minutes), unless the candidate list is otherwise empty and product allows a retry.
Only then does scoring run. Mixing a failed skill check into a numeric score lets a sufficiently close unqualified worker win. Filters are boolean. Scores order the survivors.
Score, lower is better, weights configured:
w_eta * eta_minutes + w_fair * jobs_completed_today - w_priority * slack_minutes
slack_minutes is how much earlier than due_by the worker would arrive. A job that is about to miss its window gets a negative slack and sorts first. Fairness adds minutes of penalty per job already done today so the nearest worker is not the default forever. The weights are the product decision. The interview answer names them and says who tunes them.
Geo index and stale fixes
H3. A fix maps to a cell at resolution 8 (city-block scale; the exact edge length is defined by the H3 spec, on the order of a few hundred meters). The hot index is cell_id -> set of worker ids plus worker_id -> {lat, lng, fix_time, cell}. gridDisk returns the neighboring cells to search outward until enough fresh, skilled candidates appear or the ETA cannot meet the window. H3 is open source and has stable bindings. We do not need a proprietary geo database to get a first candidate set.
Redis GEO. GEOADD on each fresh fix and GEOSEARCH by radius is shorter to operate if Redis is already in the platform. It returns distance, not road ETA. It is a fine filter stage. It is a poor system of record. Redis loss rebuilds from the latest fix table, which is allowed to lag the true sidewalk by the freshness window.
PostGIS stores jobs and, if needed, a recent fix for replay and disputes. It is not on the path of 20,000 writes per second plus a radius query per assignment unless you have sized it for that. The hot path is the in-memory index. PostGIS answers "what happened this morning" and batch planning over a city.
Stale policy: the ingest consumer writes the fix only if its device timestamp is newer than the stored one, compared with a small skew allowance. A delayed packet must not rewind a worker. On-demand eligibility requires server_received_at within 30 seconds as well, so a device with a wrong clock cannot stay "fresh" forever. If those two disagree beyond an extreme skew, drop the fix and count it.
Offers are leases
Human accept takes seconds or tens of seconds. Holding a database row lock that long exhausts the pool. The offer is a row with expires_at. Creating it is a conditional update:
UPDATE jobs SET state = 'OFFERED', offer_id = ? WHERE job_id = ? AND state = 'OPEN'
and
UPDATE workers SET live_offer_id = ? WHERE worker_id = ? AND live_offer_id IS NULL
Both in one transaction. If either updates zero rows, roll back. The worker's app accepts with POST /v1/offers/{id}/accept before expires_at. Accept is UPDATE offers SET state = 'ACCEPTED' WHERE offer_id = ? AND state = 'OFFERED' AND expires_at > now(). A sweeper flips expired offers and clears live_offer_id. Two sweepers are safe because the update is conditional.
The worker is not removed from the geo index while offered; they are ineligible via live_offer_id. Removing them from the index and reinserting on decline races with location updates.
Batch planning and rebalancing
On-demand jobs (window starts now) run the loop immediately. Scheduled jobs (a proctor booking for tomorrow) enter a planner that runs every minute. The planner builds a tentative matching for a city: greedy by slack, then fairness, writing recommendations a dispatcher confirms, or auto-offers if the tenant turned auto-offer on. Batch is where global fairness is possible. Pure greedy online assignment cannot see the job that will appear in five minutes. Say that limitation. You can reserve a fraction of workers as slack capacity per cell so the online algorithm does not empty a neighborhood.
Rebalancing looks at fresh idle workers per H3 cell versus open jobs and upcoming windows. Cells with surplus idle workers and cells with unmet demand produce a suggestion: "move toward cell X." The worker accepts a reposition task or ignores it. A suggestion is not an ACCEPTED job and does not pay a fare; if the business pays for repositioning, that is a real job type with a small duration, created explicitly. Silent teleportation of the map pin is a bug, not a rebalance.
ETA
Filter with H3 distance or great-circle. For the best K candidates (K = 5), call OSRM GET /route/v1/driving/{lon1},{lat1};{lon2},{lat2}?overview=false and read duration. Cache the result by origin cell, destination cell, and a two-minute bucket. If OSRM's circuit is open, score with great-circle distance times a configured road factor (for example 1.4) and mark the offer eta_confidence = low. Do not call the router once per worker in the city. K is the cost control.
Customer-visible ETA is a band (arrive between 14:10 and 14:25), not a single minute, especially on low confidence. The band widens when the fix is close to the freshness limit.
Architecture and Design Diagrams
Location ingest scales on its own. The assignment transaction touches PostgreSQL and a small number of index reads. Routing is a dependency with a timeout.
mermaidflowchart LRsubgraph actors [Actors]WorkerApp[Worker app]DispatchUI[Dispatcher]Customer[Customer status]endsubgraph edge [Edge]LB[L7 load balancer]endsubgraph dispatch [Dispatch]Ingest[Location ingest]Assign[Assignment API]Batch[Batch planner]Sweep[Offer sweeper]DB[(PostgreSQL jobs and offers)]Geo[Hot geo index]Cache[ETA cell cache]endsubgraph deps [Dependencies]Route[OSRM router]endWorkerApp --> LB --> IngestIngest --> GeoDispatchUI --> LB --> AssignAssign --> GeoAssign --> DBAssign --> CacheAssign --> RouteBatch --> DBBatch --> GeoSweep --> DBCustomer --> LB --> Assign
mermaidsequenceDiagramparticipant A as Assignment APIparticipant G as Geo indexparticipant R as OSRMparticipant DB as PostgreSQLparticipant W as Worker appA->>G: Fresh skilled workers near job cellA->>A: Hard-filter shift, capacity, cooldownA->>R: ETA for top K candidatesR-->>A: Durations or timeoutA->>A: Score ETA plus fairnessA->>DB: Conditional OFFERED and live_offer_idalt Lease acquiredDB-->>A: Offer rowA->>W: Push offerW->>A: Accept before expiryA->>DB: ACCEPTED if unexpiredelse Lost the raceDB-->>A: Zero rows updatedA->>A: Try next candidateend
| Component | Owns |
|---|---|
| Location ingest | Latest fix per worker, H3 or GEO update, stale eviction |
| Assignment API | Eligibility, score, offer transaction, accept and decline |
| PostgreSQL | Jobs, workers' shifts and skills, offers, attempt history |
| Hot geo index | Who is fresh and in which cell. Rebuildable |
| OSRM | Road duration for K candidates. Optional at runtime |
| Batch planner | Recommendations for future windows, surplus and deficit cells |
| Sweeper | Expires offers. Single logical effect via conditional updates |
Technical Explanation
Capacity
Assumptions:
- 50,000 workers on shift at peak, fix every 5 seconds when the app is foregrounded: 10,000 fixes per second average, design 20,000 per second to cover jitter and a shorter 3-second interval in dense cities.
- 200,000 jobs per day. On-demand fraction 20%: 40,000 immediate assignments. The rest are batch. Peak on-demand 20 assignments per second during a morning rush. Each looks at a few cells and calls the router for at most 5 candidates: 100 route calculations per second peak, heavily cacheable by cell pair.
- Offer accept timeout 20 seconds. Live offers outstanding at peak are on the order of
20 assignments/s * 20 s = 400, trivial for PostgreSQL. - Location payload ~100 bytes. 20,000 per second is about 2 MB/s ingress. The cost is index updates, not bandwidth.
The hot geo index must absorb 20,000 updates per second. A single Redis with GEOADD can do this for a city-sharded key if you shard by region so one city's command stream stays on one shard and you do not require a global sorted set. H3 in memory, partitioned by cell prefix across processes, also works. Do not send every fix through a transaction that updates a PostGIS point and a job table. The latest-fix write can be a Redis hash HSET worker:{id} plus the cell set update, and a sampled or batched archive to PostgreSQL for the 7-day dispute log (for example persist one fix per worker per 30 seconds). Losing a single intermediate fix is acceptable. Losing the assignment transaction is not.
PostgreSQL write rate is offers and state changes, tens per second average, low hundreds at peak. One primary is enough. The failure mode to design for is lock contention on a popular worker row, which the conditional live_offer_id update already serializes correctly. Keep that transaction to those two updates. Do not call OSRM inside it.
APIs we own
POST /v1/jobs with idempotency key, skills, lat, lng, window, duration, priority. Returns OPEN.
POST /v1/jobs/{id}/assign runs the on-demand loop. Returns the offer or 409 if the job is no longer OPEN, or 200 with UNASSIGNED_ALERT if the candidate list is empty. Idempotent: a retry returns the live offer if one exists.
POST /v1/workers/{id}/locations with device timestamp, lat, lng. Authenticated as that worker. Rate limited to a few fixes per second so a broken client cannot rewrite the index.
POST /v1/offers/{id}/accept and /decline. Accept after expiry returns 409 and the job is OPEN again if the sweeper has run, or accept triggers the same expiry transition if it hits first.
GET /v1/jobs/{id} for the customer returns state and an ETA band. It does not return the worker's coordinate history. GET /v1/dispatch/jobs/{id} for a dispatcher returns offer attempts and the worker's current cell.
POST /v1/plans/run is internal, once a minute per city, producing recommendations.
Rebalance suggestions are GET /v1/workers/{id}/suggestions. Accepting one creates a real reposition job via POST /v1/jobs with type reposition. Ignoring it writes nothing.
Schema
sqlCREATE TABLE jobs (job_id uuid PRIMARY KEY,tenant_id uuid NOT NULL,state text NOT NULL,required_skills text[] NOT NULL,lat double precision NOT NULL,lng double precision NOT NULL,not_before timestamptz NOT NULL,due_by timestamptz NOT NULL,duration_min int NOT NULL,priority int NOT NULL,live_offer_id uuid,version int NOT NULL DEFAULT 0);CREATE TABLE workers (worker_id uuid PRIMARY KEY,tenant_id uuid NOT NULL,skills text[] NOT NULL,shift_start timestamptz NOT NULL,shift_end timestamptz NOT NULL,capacity int NOT NULL,live_offer_id uuid,jobs_today int NOT NULL DEFAULT 0);CREATE TABLE offers (offer_id uuid PRIMARY KEY,job_id uuid NOT NULL,worker_id uuid NOT NULL,state text NOT NULL,eta_seconds int,eta_confidence text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz NOT NULL);CREATE TABLE offer_attempts (job_id uuid NOT NULL,worker_id uuid NOT NULL,offered_at timestamptz NOT NULL,PRIMARY KEY (job_id, worker_id, offered_at));
jobs_today increments when an offer is accepted, in that transaction, and resets on a schedule at the tenant's local midnight. Fairness that reads a global counter from a replica can lag; reading it in the assignment transaction from the primary is enough at 20 assignments per second.
Latest location is not in this schema on purpose. A table of every fix at 10,000 rows per second is 864 million rows per day. The hot key is in Redis. An archive table stores downsampled points with a 7-day retention and a partition dropped each day. Dispute resolution uses the archive, not the hot index.
Scale, cache, and load balancing
Location ingest is its own deployment behind the L7 balancer, many instances, no session affinity. Assignment API is a smaller pool. They do not share a thread pool, so a flood of fixes cannot delay an accept click. The router has a client-side bulkhead: at most K in-flight requests per assignment and a cap on total in-flight route calls. When the cap is hit, new assignments use the low-confidence distance score rather than queueing behind the router for longer than the 500 ms decision budget.
Cache ETA by (h3_origin, h3_dest, time_bucket_2min). A city of a few thousand active cell pairs is a small Redis hash. Do not cache "worker id to job id" eligibility; it goes stale the moment an offer is created. The geo index itself is the cache of presence. Rebuild it from the latest-fix hash after a Redis loss. Assignments during rebuild may see a thinner candidate set and raise UNASSIGNED_ALERT more often for a short period. That is the availability degradation to state, not a silent wrong assignment.
Load-shed location fixes per worker if they exceed the rate, not by dropping a random city's ingest. A global drop would create false stale workers and a false shortage.
Security
Workers authenticate as themselves and may post only their own location. Dispatchers in the tenant see current cell and offer history. Customers see status and an ETA band. Raw trace export is a privileged, audited action with a job id as the reason. Location archive is encrypted at rest like the rest of the database. Do not put precise tracks in the info log.
A worker cannot accept an offer that is not theirs. The accept statement includes worker_id = current user. A dispatcher override that forces an assignment still writes an offer row and an audit actor, so the lease rules remain the single way capacity is consumed. A back door that sets jobs.state = ACCEPTED without the worker update will double-book. Do not ship that back door.
Tenant id scopes jobs and workers. A university's proctors are not candidates for another tenant's kit delivery. The geo index keys include tenant id, or the index is per tenant, so a radius query cannot cross tenants. This is the same isolation lesson 60 applies platform-wide.
Failure modes
| Failure | Behavior |
|---|---|
| Two assigners, one job | One conditional update wins. The other tries the next candidate or returns the existing offer |
| Two jobs, one worker | live_offer_id update matches one. The other worker candidate is used |
| Worker ignores the offer | Sweeper expires it, clears live_offer_id, job returns to OPEN, attempt recorded |
| Fix arrives out of order | Older device timestamp ignored |
| Fixes stop | After 30 seconds the worker is stale and leaves the on-demand set. Existing ACCEPTED job is not cancelled automatically; the dispatcher sees a stale flag |
| Router down | Low-confidence ETA, wider customer band, assignment still possible |
| Redis geo index empty | Rebuild from latest-fix hashes. Temporary rise in unassigned alerts |
| Worker accepts then the app dies | Job stays ACCEPTED. A no-show timer, longer than the offer timer, alerts the dispatcher to reassign. Reassign cancels the acceptance explicitly and frees capacity |
| Clock skew on expiry | Compare using database now(), not the application server's clock alone |
Trade-offs
H3 versus Redis GEO versus PostGIS on the hot path. H3 gives explicit neighborhoods and a clean shard key. Redis GEO gives radius with less code. PostGIS gives correctness for complex polygons and loses if you put 20,000 writes per second on the same primary as offers. Hot index in memory, polygons and audit in PostGIS.
Greedy online versus batch optimal. Online meets the 500 ms SLO and can starve a cell. Batch can see the morning's full board and cannot assign a job that appears "now." Use both. Leave slack capacity so the online path has someone left.
Auto-offer versus dispatcher confirm. Auto-offer is faster and will sometimes pick a worker the local lead would not. Confirm is safer and does not scale to 20 assignments per second of clicks. On-demand auto-offers. Next-day proctor plans are confirmed.
Hard ETA miss versus best effort. If no candidate can meet due_by even at low confidence, do not assign the nearest late worker silently. Raise UNASSIGNED_ALERT. A human can override and the audit log shows they accepted the miss.
Reposition as suggestion versus as a paid job. Suggestions are cheap and ignorable, so they may not move supply. Paid reposition jobs move supply and cost money and complexity. Start with suggestions. Add a job type when data shows ignored suggestions and missed windows in the same cells.
Practical Implementation or Design Walkthrough
On-demand proctor. A test center creates a job due in 40 minutes, skill proctor, duration 180 minutes. Assign loads the job cell, expands gridDisk until it has fresh workers with the skill or the radius implies an ETA past due_by. Three survive the hard filters. OSRM returns 12, 18, and 25 minutes. Jobs-today are 4, 0, and 1. With the configured weights the idle worker at 18 minutes beats the busy worker at 12. The transaction sets the job to OFFERED and workers.live_offer_id. The app rings. The worker accepts at second 8. State becomes ACCEPTED, jobs_today increments. A second job in the same window no longer sees this worker, because capacity and the live offer are both consumed; after accept, live_offer_id clears and an assignment row in worker_jobs holds the overlap check. Keep that table:
sqlCREATE TABLE worker_jobs (worker_id uuid NOT NULL,job_id uuid NOT NULL,starts_at timestamptz NOT NULL,ends_at timestamptz NOT NULL,PRIMARY KEY (worker_id, job_id));
The accept transaction inserts worker_jobs with a exclusion constraint on worker_id and the time range if you use btree_gist, or it checks overlaps in the transaction at this scale. The exclusion constraint is the one that survives a bug. At 20 assignments per second it is appropriate.
Stale candidate. The nearest proctor's fix is 45 seconds old. The filter drops them. They do not appear as a low score; they are absent. The dispatcher map still shows them as stale so a human can call them. The human override writes an offer with eta_confidence = manual and an actor id.
Router timeout. OSRM exceeds 150 ms. The scorer uses great-circle kilometers times 1.4 as a stand-in minute function you have calibrated, marks low confidence, and still leases the offer if the stand-in meets the window. The customer sees a wide band. If the stand-in misses the window, the job alerts instead of dispatching a guess across a barrier. Cell-pair penalties, once you have seen a router result, stay cached so a later outage remembers that those two cells are not actually ten minutes apart.
Rebalance. Cell A has 15 idle fresh workers and no jobs. Cell B has 6 open jobs due within the hour and 1 idle worker. The planner emits suggestions to the longest-idle workers in A. Two accept; each acceptance creates a reposition job through the normal offer path so they are not also grabbed by an on-demand assignment. The others stay put. No pin is moved server-side.
The lease transaction, without the router inside it:
java@Transactionalpublic boolean tryOffer(UUID jobId, UUID workerId, UUID offerId) {int job = jdbc.sql("""UPDATE jobs SET state = 'OFFERED', live_offer_id = ?, version = version + 1WHERE job_id = ? AND state = 'OPEN'""").params(offerId, jobId).update();int worker = jdbc.sql("""UPDATE workers SET live_offer_id = ?WHERE worker_id = ? AND live_offer_id IS NULL""").params(offerId, workerId).update();if (job != 1 || worker != 1) {throw new OfferRaceException();}return true;}
OfferRaceException is an application exception that marks the transaction rollback-only, so a failed worker update does not leave the job OFFERED. Tests cover that rollback, expiry versus a late accept, an older GPS timestamp ignored, and a skill mismatch that never reaches scoring.
Enterprise Real-World Scenarios
Scenario 1: A field-service company that is not a ride app
A national company sends technicians to install equipment. Leadership asks engineering to "use the ride-hailing design" because both maps show cars. The ride design matches one rider to one driver for an immediate pickup and prices a trip. Technicians have certifications, four-hour jobs, parts in the van, and appointments booked yesterday. Applying the ride matcher double-books certified technicians on overlapping windows and treats a two-minute-stale GPS as a reason to skip the only person who can do the job, with no dispatcher override.
Dispatch, as specified here, models skills and windows as hard filters, uses batch planning for tomorrow's board, and keeps an audited override. Location frequency stays high so on-demand repairs still find someone. The company measures missed window rate and share of jobs taken by the top 10% of workers. The second metric catches a fairness weight of zero, which recreates "always nearest" and burns out the downtown crew. They do not implement surge pricing. If demand exceeds supply, the signal is UNASSIGNED_ALERT and a hiring or subcontracting decision, not a multiplier on a fare.
Scenario 2: Kit delivery during a campus rush shares the platform with proctors
TechLearningPro runs kit couriers and exam proctors on one dispatch tenant with different skill tags. A courier without proctor never enters a proctor candidate list, even if they are standing outside the test center. During move-in week, courier jobs spike. The location ingest pool grows. The assignment pool stays sized for decisions, not for pings. Proctor offers keep their p99 because routing calls are bulkheaded and courier location writes are a different service.
A courier's phone freezes in a basement. After 30 seconds they drop out of on-demand courier assignment. The kit job they already ACCEPTED stays theirs, and the customer ETA band widens because freshness decayed. A no-show timer at ten minutes alerts dispatch to reassign rather than cancelling at second 31 and creating a second delivery of the same kit. Reassign is explicit. The attempt table shows who already held the job so the planner does not offer it back immediately.
The campus security office asks for a live map of every courier for the police. That export is not the customer API. It is a privileged feed, time-boxed, audited, and omitted from the default dispatcher role. Architecture Security and Threat Modeling treats continuous location as sensitive data with a retention of days, not as a log line.
Best Practices
- Filter on skills, shift, capacity, freshness, and cooldown before any score.
- Lease offers with conditional updates and an expiry sweeper. Do not hold row locks while a human decides.
- Keep location ingest off the assignment database path. Archive a sample for disputes.
- Ignore location fixes that would rewind a worker's timestamp.
- Call the router only for the top K after a cheap geo filter. Cache cell-pair ETAs briefly.
- Degrade to a low-confidence ETA when the router is down, and show that confidence.
- Put fairness in the score as an explicit weight, then measure concentration of work.
- Use batch planning for future windows and online assignment for "now." Leave slack in hot cells.
- Make rebalance a suggestion or an explicit job. Do not move the canonical pin from the server.
- Scope geo keys and queries by tenant.
- Authorize traces. Minimize and expire them.
- Alert on offer expiry rate, unassigned-due-soon, router timeout rate, and fraction of stale workers.
- Test the lost-race transaction and the late accept.
Common Mistakes and Anti-Patterns
Using the ride-hailing state machine unchanged. Pickup, arriving, on-trip, and surge do not express a four-hour certified job. You will hide skills inside a "vehicle type" field and double-book people. Model the job and the window.
Scoring workers who fail a hard constraint. A weight cannot repair a missing certification. Filter first.
Calling the routing engine for every worker in range inside the database transaction. You will exhaust connections and still miss the latency SLO. Filter, score outside, then a short transaction.
Treating a stale fix as slightly worse rather than ineligible for on-demand. A phone that died an hour ago looks like a worker standing at the last tower. They will miss the window. Stale means out of the automatic set. Humans can override.
Assigning without a lease. Two consumers read the same idle worker and both mark the job assigned. The conditional updates are the design, not an optimization.
Global fairness that requires a distributed lock per city. jobs_today on the worker row, updated in the accept transaction, is enough at this rate. A perfect optimal matching of the whole country is the batch planner's aspiration, not the online path's lock.
Logging raw tracks. Support tooling becomes a surveillance archive you did not threat-model. Log job ids and cell ids.
Rebalance by editing coordinates. The map lies, the next ETA is fiction, and the worker's app fights the server. Suggest, or create a real reposition job.
Troubleshooting and Design Review
| Symptom | Likely cause | Correction |
|---|---|---|
| Double-booked worker | A path sets job state without worker_jobs or live_offer_id | One write path. Exclusion or overlap check. Test the rollback |
| Rising missed windows in one neighborhood | Online greedy drained the cell; suggestions ignored | Slack capacity. Review suggestion accept rate. Consider paid reposition |
| Assignment p99 tracks router p99 | Router called inside the transaction or K too large | Bulkhead, K = 5, cache cell pairs, time-box |
| Many stale workers after a deploy | Ingest pool crashed or a bad build stopped the app's pings | Separate ingest SLO. Do not expire ACCEPTED jobs on staleness alone |
| Same worker gets every nearby job | Fairness weight is zero or jobs_today not incremented | Check the score config and the accept transaction |
| Late GPS overwrites a new fix | Compare device timestamps before replace | Reject older timestamps. Metric the rejects |
| Customer sees a pinpoint that jumps | UI rendering archive points or another worker | Customer API returns a band only |
| Cross-tenant candidate | Geo key omitted tenant id | Prefix keys. Add a test with two tenants in one cell |
Review questions:
- What is the update rate, and what is the decision rate?
- Which constraints are filters, and which are weights?
- What is the lease, and what expires it?
- What happens when OSRM is down?
- How does a stale worker affect on-demand assignment and an already accepted job differently?
- Where is location retained, and who can read it?
Interview Questions and Answers
Q1. How is this different from designing ride booking?
Ride booking matches a passenger to a driver for an immediate pickup and then runs a trip: arriving, on trip, complete, priced. Dispatch assigns a job that already has a place, a skill requirement, and a time window to a worker, and the "trip" may be three hours of proctoring with no passenger. I still need a geo index, fresh locations, and an ETA. I do not need surge, a rider cancellation policy, or a live share of the driver's pinpoint with a passenger as the core feature. Customers get an ETA band. If the interviewer wants the ride product, I will design that; I will not pretend a fare meter solves overlapping certified jobs. The lease, the skill filter, and the batch board are the pieces ride designs often skip.
Q2. What is your location update rate, and where do those writes go?
Fifty thousand active workers at one fix per five seconds is ten thousand per second, and I provision twenty thousand. Those writes go to a hot index, Redis GEO or an H3 cell map, plus a latest-fix record. They do not each open a PostgreSQL transaction. I archive a downsampled track for a short retention. Assignment transactions are a separate, much smaller rate, tens per second, and they are the ones that must be correct. Sizing one database for both is how offers time out while the disk ingests GPS.
Q3. How do you find candidate workers quickly?
Index fresh workers by H3 cell or by a geo set sharded per region and tenant. From the job's cell, search neighboring cells outward with gridDisk or a radius search until the candidate count is enough or the distance cannot meet the deadline. Then apply hard filters in memory against worker records: skills, shift, capacity, live offer, cooldown. Only the top few by straight-line distance go to the router. The index rebuilds from latest fixes if it is lost. I do not scan all fifty thousand workers per job.
Q4. How do you prevent two jobs from taking one worker, and two workers from taking one job?
In one database transaction, update the job from OPEN to OFFERED only if it is still OPEN, and set the worker's live_offer_id only if it is null. Zero rows on either statement rolls back both. Accept inserts a worker_jobs interval and is conditional on the offer being unexpired. A sweeper expires offers with the same conditional style so two sweepers do not double-free. The human's think time is not a lock. It is an expires_at. This is the lesson 36 lease applied to a row you can see.
Q5. What do you do with stale GPS?
If the server has not received a fix within thirty seconds, the worker is ineligible for new on-demand offers. I do not merely lower their score; a dead phone looks stationary. I also reject a fix whose device timestamp is older than the one stored, so delayed packets do not rewind the pin. An already accepted job is not auto-cancelled when the phone goes quiet. A longer no-show timer alerts a dispatcher to reassign explicitly. Batch planning may still consider a slightly stale worker with a penalty and a lower confidence, because tomorrow's plan can be confirmed by a human.
Q6. How is ETA computed, and what if the router fails?
Great-circle or H3 distance eliminates the impossible set. OSRM's documented route endpoint supplies duration for the best few candidates, with a timeout around 150 milliseconds, outside the database transaction. Results cache by origin cell, destination cell, and a short time bucket. If the router circuit is open, I apply a configured road factor to straight-line distance, mark confidence low, widen the customer-visible band, and refuse the assignment when even that pessimistic stand-in misses due_by. I do not invent a proprietary distance-matrix call. I would rather alert UNASSIGNED_ALERT than promise a minute I cannot support.
Q7. Where does fairness live?
In the score, after filters, as a penalty for jobs_today, and in the batch planner which can see a whole shift. Online assignment is greedy and will still bias toward dense demand; the weight reduces the bias and a weekly histogram of jobs per worker shows whether it worked. I do not take a global lock to compute a perfect matching on the online path. If the top ten percent of workers still take half the jobs, the weight or the slack-capacity rule changes. Fairness is an operated metric, not a slogan in the scorer.
Q8. How does rebalancing work without becoming ride-hailing?
Compare idle fresh workers and upcoming open jobs per cell. Suggest that long-idle workers in surplus cells move toward deficit cells. If they accept, create a normal reposition job through the offer lease so they are reserved for real. If they ignore it, nothing about their stored location changes. I do not drag the pin, and I do not pay a fictional trip unless finance has defined a reposition job type. The goal is supply in the right neighborhood before the window, not a marketplace that manufactures rides.
Q9. What does the customer see, and what must they not see?
State of their job and an ETA band that widens when confidence is low or the fix is aging. Not the worker's historical track, not other jobs on that worker, not a live precise pin as a default. Dispatchers see the current cell, offer attempts, and skills. A full trace is a privileged audited export tied to a dispute. Retention of the archive is short and partitioned so expiry is a drop, not a long delete. Location is sensitive personal data. The API list is the control.
Q10. How do scheduled jobs and on-demand jobs share workers?
Scheduled jobs run through the batch planner and become accepted intervals in worker_jobs when confirmed. On-demand assignment treats those intervals as a hard overlap filter. Slack capacity per cell keeps the planner from filling every worker and blocking emergencies. If product wants emergencies to preempt, preemption is an explicit cancel of a low-priority job with an audit reason, not a double booking. The overlap constraint is what makes preemption visible: you must remove the old interval to insert the new one.
Q11. A worker's accept request and the sweeper run at the same time. Who wins?
Both issue a conditional update on the offer row: accept requires state = OFFERED and expires_at > now(), and the sweeper requires state = OFFERED and expires_at <= now(). Exactly one updates a row. The loser sees zero rows. Accept then returns 409. The sweeper's clear of live_offer_id is in the same transaction as the expiry, so a worker is not left unable to take work. Use the database clock. Do not compare expiry on the application instance that might be skewed. This is a small transaction and it wants a test with two concurrent sessions.
Q12. How would you defend the 500 millisecond decision SLO in a Module 6 review?
I split the budget: geo lookup and filters under 50 milliseconds, at most five router calls under 150 milliseconds in parallel, scoring negligible, lease transaction under 30 milliseconds, and the rest as margin and tail. The router is bulkheaded so a stall becomes low-confidence mode instead of an unbounded wait. Location ingest cannot steal the assignment pool's CPU. I show a trace from lesson 43's style of spans for one decision. I show the miss-window metric and the fairness histogram beside the latency SLO, because a fast assignment of the wrong skill is a defect. Architecture Performance and Capacity Review gets the rate table. Disaster Recovery and Business Continuity gets the Redis rebuild and the fact that offers live in PostgreSQL, so a cache loss does not double-assign.
Hands-On Exercises
Exercise 1: Assign a proctor
Problem. Fifty thousand workers, fixes every five seconds. A proctor job is due in forty minutes, skill tag proctor, three-hour duration. One nearby worker is stale, one lacks the skill, one is closest but has four jobs today, one is idle and eight minutes farther by road.
Tasks.
- State filters versus score weights.
- Compute ingest rate and peak assignment rate you assume.
- Specify the geo query and the K router calls.
- Write the offer transaction and the expiry rule.
- Say what the customer API returns.
- Say what happens when OSRM times out.
- Include fairness so the idle worker can win.
- Define staleness and the no-show timer separately.
Deliverables. Candidate table after filters, sequence of the lease, and the degraded-router behavior.
Exercise 2: Review a ride-app clone
The proposal matches the nearest moving pin, locks the worker row until they tap accept, stores every GPS point in the primary database, shows the customer a live precise map, and uses one code path for couriers and certified proctors with no skills. Write the review. Cover double booking, stale pins, lock pools, privacy, and overlapping windows.
Rubric.
| Area | Weight |
|---|---|
| Requirements distinct from ride booking | 15% |
| Eligibility, scoring, and offers | 25% |
| Geo index, ETA, and staleness | 20% |
| Capacity split and leases | 20% |
| Privacy, fairness, and trade-offs | 10% |
| Clarity | 10% |
Lesson Summary
TechLearningPro Dispatch assigns skilled workers to jobs with time windows. Locations stream into a rebuildable geo index at tens of thousands of fixes per second. Assignments are rare by comparison and run as short conditional transactions that lease a job and a worker together. Stale fixes leave the on-demand set and do not cancel accepted work. ETAs use a cheap filter, then OSRM for a handful of candidates, then a low-confidence fallback. Fairness is a weight and a metric. Rebalancing suggests or creates an explicit reposition job. Customers see a band, not a trace.
The ride-booking case study remains the place for trips and fares. This design refuses to inherit that state machine. Module 6's interview framework, capacity review, security lesson, and disaster-recovery lesson are where you show the two rates, the lease, and the index rebuild.
Further Reading
- Distributed Locking and Coordination, Message Queues and Event Streaming, and Circuit Breaker, Retry and Bulkhead Patterns.
- Design a Ride Booking Platform for the trip-and-fare problem this lesson deliberately does not solve.
- System Design Interview Framework, Architecture Performance and Capacity Review, Architecture Security and Threat Modeling, and Disaster Recovery and Business Continuity.
- H3, the open geospatial grid: https://h3geo.org/.
- OSRM HTTP API, including
GET /route/v1/{profile}/{coordinates}: https://github.com/Project-OSRM/osrm-backend/blob/master/docs/http.md. - Redis
GEOSEARCH: https://redis.io/docs/latest/commands/geosearch/. - PostGIS distance queries, for the durable and batch path: https://postgis.net/docs/ST_DWithin.html.