DEV Community

Cover image for Three washers. One job. Zero locks.
Onkar Deokate
Onkar Deokate

Posted on

Three washers. One job. Zero locks.

One wash job, three accepts: the WHERE clause decides, and a CHECK constraint guards the photos.

A driver parks, opens ParkEase and books a car wash. The job goes out to up to three nearby washers at once, because the first one might be on a break and the driver shouldn't wait.

All three see it. All three tap Accept.

Exactly one of them can have it. The other two need to hear "this one's gone, more coming," not an error page, not a silent spinner, and definitely not a second washer turning up at the same car.

Yesterday I promised there isn't a single lock in the code. There isn't. Here's what there is.

The WHERE clause is the lock

The usual answers are SELECT … FOR UPDATE, an advisory lock, or a retry loop. ParkEase uses none of them. The accept is one UPDATE, and its WHERE clause only matches a job that is still on offer and still has no washer:

.where(
  and(
    eq(washJobs.id, input.jobId),
    eq(washJobs.status, 'offered'),
    isNull(washJobs.washerUserId),
  ),
)
.returning();

const accepted = updated[0];
if (accepted === undefined) throw new WashJobAlreadyTakenError();
Enter fullscreen mode Exit fullscreen mode

Postgres serialises updates to the same row. The first accept flips the job to accepted. By the time the second and third get their turn, the row no longer matches, so they update zero rows. Zero rows becomes a 409 with copy a washer can read: This job was taken by another partner. More jobs coming!

It's the same move as the booking system earlier in this series: don't arrange for the race not to happen. Let the database answer it.

One more thing is deliberately missing from that transaction: the payment gateway. Creating the payment order inside the accept would mean the two losers each left an orphan order at the gateway that no webhook would ever match. So the order is created later, by the driver's own pay action.

No photo, no wash. The database agrees.

A wash can't start without a before photo, and can't finish without an after photo. The app greys out the buttons, but the app isn't the guard. The row is:

ADD CONSTRAINT wash_jobs_photo_gate_check
CHECK ((status <> 'washing'   OR before_photo_id IS NOT NULL)
AND (status <> 'completed' OR (before_photo_id IS NOT NULL
AND after_photo_id IS NOT NULL)));
Enter fullscreen mode Exit fullscreen mode

No code path, admin tool or future bug can produce a completed wash with no evidence it happened. The command checks the same rule first, purely to turn a constraint name into a sentence a washer can act on:

if (input.event === 'start_washing' && job.beforePhotoId === null) {
  throw new BeforePhotoRequiredError();
Enter fullscreen mode Exit fullscreen mode

Proven over real HTTP

The integration suite runs the real API against a real Postgres in a throwaway container. Three washers accept the same job and the statuses come back [200, 409, 409]. A retried accept with the same idempotency key replays the original answer instead of racing itself. The photo gates refuse in both directions. 84 tests, all passing.

One honest detail: the three accepts in that test run one after another, not truly in parallel. That's on purpose. The test harness shares its "who am I" state between requests, so a parallel version would pass for the wrong reason. The race being proven lives in the database's WHERE clause, and each accept still meets a row the previous one already moved.

What I'd tell myself before starting

Every time I've reached for a lock, the database already had a better one: a constraint, an exclusion, a WHERE. Write the invariant where nothing can route around it.


Tomorrow: the last role video. What a car washer actually does in ParkEase: their own menu, jobs that come to them, and the two photos that bookend every wash. Follow the series.

ParkEase is a peer-to-peer parking marketplace for India, built solo and launching soon on Android.
Code: https://github.com/Deonkar/parkease · Music: ende.app (CC BY 4.0)

Top comments (0)