Concurrency, Races & Locks
Learning Objectives
- You can construct a concrete check-then-act race.
- You can protect a state-dependent invariant using a database transaction and row lock.
- You can test competing operations with independent connections and state assertions.
A transaction makes its participating writes commit or roll back together. That alone does not guarantee that decisions made by competing requests remain valid.
A handler executes instructions in order, but many handlers can be in progress at once. await pauses one operation while others can run. A single JavaScript thread therefore does not imply that database-backed application operations are serialized.
A check-then-act race
A workshop has one remaining seat. Two requests read that value before either changes it:
Figure 1 shows one possible interleaving of the unprotected operations. Neither request reserves the seat merely by reading its availability.
Both decisions were individually plausible when made from their snapshots. Together they violate the invariant that accepted registrations cannot exceed capacity.
Wrapping each request in a transaction does not prevent both ordinary reads from seeing one seat. Reading availability does not reserve it for that request.
Add the workshop schema
In practice/, add database-migrations/004_workshops.sql, using the next unused
number. These new tables model available workshop seats and accepted registrations.
-- migrate:upCREATE TABLE lab_workshops ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, seats_remaining INTEGER NOT NULL CHECK (seats_remaining >= 0));CREATE TABLE lab_registrations ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, workshop_id INTEGER NOT NULL REFERENCES lab_workshops(id), participant TEXT NOT NULL, UNIQUE (workshop_id, participant));-- migrate:downDROP TABLE lab_registrations;DROP TABLE lab_workshops;The nonnegative-seat check prevents a negative quantity, the foreign key keeps registrations attached to existing workshops, and the unique pair prevents the same participant from registering twice.
From practice/, apply the development migration, then recreate only the isolated
test project. The final command applies the entire migration chain to its fresh database:
docker compose run --rm database-migrationsdocker compose -p wsd-practice-test -f compose.yaml -f compose.test.yaml downdocker compose -p wsd-practice-test -f compose.yaml -f compose.test.yaml up -d --build database database-migrationsLock the row that coordinates the rule
Both requests need to coordinate through the same existing row: the workshop.
SELECT ... FOR UPDATE locks that row until the transaction commits or rolls
back. Another transaction requesting the same lock waits. Once it acquires the
lock, it checks the current seat count before deciding whether to register.
Requests for different workshops can lock different rows.
The
SELECT ... FOR UPDATEstatement does not block ordinary reads of the same row. It only blocks other transactions that attempt to acquire the same lock. This allows concurrent reads while ensuring that only one transaction can modify the row at a time.
Now choose the coordination row for a different scarce resource.
Two checkouts for one camera
0 / 5 points
Two checkouts may create different loan rows for the same camera. The rule permits only one active loan, and no new loan row exists when the decision starts. Which existing row should both transactions lock before they check active loans?
Create api/src/repositories/workshop-repository.ts. The names make the lock and
writes visible to the service; all three operations require its transaction:
import type postgres from "postgres";
export const lockWorkshop = async (tx: postgres.TransactionSql, workshopId: number) => { const [workshop] = await tx` SELECT id, seats_remaining FROM lab_workshops WHERE id = ${workshopId} FOR UPDATE `; return workshop ?? null;};
export const insertRegistration = async ( tx: postgres.TransactionSql, workshopId: number, participant: string,) => { const [registration] = await tx` INSERT INTO lab_registrations (workshop_id, participant) VALUES (${workshopId}, ${participant}) RETURNING id `; return { id: Number(registration.id), workshopId, participant };};
export const takeSeat = async (tx: postgres.TransactionSql, workshopId: number) => { await tx` UPDATE lab_workshops SET seats_remaining = seats_remaining - 1 WHERE id = ${workshopId} `;};Create api/src/services/workshop-service.ts. As in Chapter 7, it imports the
shared client from api/src/database.ts; callers pass only the workshop ID and
participant. It acquires the coordination lock
before deciding whether registration is permitted, then completes both writes in
the same transaction:
import { sql } from "@src/database.ts";import * as workshops from "@src/repositories/workshop-repository.ts";
export class RegistrationError extends Error { code: "NOT_FOUND" | "NO_SEATS";
constructor(code: RegistrationError["code"]) { super(code); this.code = code; }}
export const registerForWorkshop = async ( workshopId: number, participant: string,) => { return await sql.begin(async (tx) => { const workshop = await workshops.lockWorkshop(tx, workshopId); if (!workshop) { throw new RegistrationError("NOT_FOUND"); }
if (workshop.seats_remaining <= 0) { throw new RegistrationError("NO_SEATS"); }
const registration = await workshops.insertRegistration(tx, workshopId, participant); await workshops.takeSeat(tx, workshopId);
return registration; });};If the first request takes the last seat and commits, the waiting request reads
zero seats and receives NO_SEATS. Every operation making this registration
decision must lock the workshop before checking availability. Ordinary reads can
still proceed; a row lock does not block all access to the table.
The database constraints remain useful, but a constraint failure is not the same
as the service’s deliberate NO_SEATS outcome. The unique participant constraint
also cannot prevent two different participants from competing for the last seat.
Keep the protected operation short: acquire the coordinating lock, check current state, and complete the database writes. Avoid waiting for a person or calling an external service while holding the lock. Apply this pattern to a different resource in the next exercise.
Protect one active equipment loan
0 / 20 points
Files in the editor
The editor below contains all 4 files for this exercise, although only one
opens initially. Use the Files panel to expand api/src/ and its folders,
then select a filename to open it. If the panel is collapsed, choose
Show file tree in the editor toolbar.
Edit:
api/src/services/loan-service.ts
These supporting files are also in the editor. You may read them; keep their supplied implementations unchanged:
api/src/schemas/loan-schema.tsapi/src/database.tsapi/src/repositories/loan-repository.ts
Task
Complete api/src/services/loan-service.ts. Import the shared sql client
from @src/database.ts. It supports multiple connections; the grader prepares
these tables in its exercise database:
lab_items(id INTEGER PRIMARY KEY, name TEXT NOT NULL)lab_loans(id identity PRIMARY KEY, item_id REFERENCES lab_items, borrower TEXT NOT NULL, returned_at TIMESTAMPTZ NULL)- a unique partial index on
lab_loans(item_id) WHERE returned_at IS NULL
A partial unique index checks only rows matching its WHERE condition. Here the supplied index permits several returned loans while limiting unreturned loans for one item to one. It is already installed by the fixture; add no migration.
The starter supplies schemas/ and transaction-aware repositories/ modules.
Implement the use case in services/: it owns sql.begin, chooses when to
query or write, and passes the same tx to every participating repository call.
Repository helpers must not open a transaction or import a different pool.
The supplied database.ts exports the shared postgres.js client. The grader
configures its database and creates the PostgreSQL fixture. You need no
HTTP server or earlier exercise. Helpers in api/src/repositories/loan-repository.ts
are lockItem(tx,itemId) (true if found), hasActiveLoan(tx,itemId) (Boolean),
and insertLoan(tx,itemId,borrower) (the returned loan object). Use the supplied
LoanInputSchema and import helpers via @src/repositories/loan-repository.ts.
@src/ maps to api/src/; postgres and zod are supplied imports.
Requirements
Export borrowItem(itemId,borrower) and LoanError with a code. Reject an
item ID that is not a positive safe integer or a blank borrower as INVALID_INPUT.
In one transaction, lock the requested item row, report NOT_FOUND if absent,
then check for an active loan. Report ALREADY_LOANED if one exists. Otherwise
insert a loan and return {id,itemId,borrower} after commit. Preserve the supplied
nonblank borrower text. A returned old loan does not block a new loan.
Two borrowers competing for the same item must yield exactly one accepted loan
and one ALREADY_LOANED rejection. Both must not be accepted. Different items
can each be borrowed. Do not serialize the whole app in JavaScript or close the
shared pool; use the coordinating database row and recheck after its lock.
A string refinement can reject blank text without transforming the supplied nonblank text. Zod’s integer schema rejects integers outside the safe range.
Check and submit
Choose Submit solution below the editor to run the grading checks and read their feedback. All 4 editor files are submitted together, including supporting files and files that are not open as tabs. This editor has no Run control.
Four checks award 5 points each: ordinary and missing input, existing/returned loans, competing same-item outcome, and independent items. Tests inspect database rows as well as results. A concurrent launch is useful outcome evidence, not a proof of every possible interleaving.
Test competing service calls
The test starts two registrations for one remaining seat. Expect one accepted
registration, one NO_SEATS rejection, zero remaining seats, and one stored
registration. Check the rejection code as well as the counts: a database or
connection error must not masquerade as the expected application decision.
The pool must allow multiple connections to the same database. A pool limited to
one connection would run the transactions one at a time before they could
compete for a row lock. Promise.allSettled waits for both attempts, including a
rejected one, before the test inspects the results and cleans up.
Add api/tests/helpers/workshop-fixture.ts. It inserts one workshop and cleans up
its registrations before removing it. The caller owns the pool:
import type postgres from "postgres";
export const withWorkshop = async ( sql: postgres.Sql, seats: number, run: (id: number) => Promise<void>,) => { const [row] = await sql` INSERT INTO lab_workshops (seats_remaining) VALUES (${seats}) RETURNING id `; try { await run(row.id); } finally { await sql`DELETE FROM lab_registrations WHERE workshop_id = ${row.id}`; await sql`DELETE FROM lab_workshops WHERE id = ${row.id}`; }};Create api/tests/workshop-service_test.ts:
import { assertEquals } from "@std/assert";import { sql } from "@src/database.ts";import { registerForWorkshop, RegistrationError } from "@src/services/workshop-service.ts";import { withWorkshop } from "./helpers/workshop-fixture.ts";
Deno.test("workshop registration coordinates stored state", async (t) => { try { await t.step("one remaining seat accepts one participant", () => withWorkshop(sql, 1, async (id) => { const results = await Promise.allSettled([ registerForWorkshop(id, "Mira"), registerForWorkshop(id, "Toni"), ]); assertEquals(results.filter((r) => r.status === "fulfilled").length, 1); const failure = results.find((r) => r.status === "rejected"); if (!failure || failure.status !== "rejected") { throw new Error("Expected a rejected registration"); } assertEquals(failure.reason instanceof RegistrationError, true); assertEquals(failure.reason.code, "NO_SEATS"); const [row] = await sql`SELECT seats_remaining FROM lab_workshops WHERE id = ${id}`; const [count] = await sql`SELECT count(*)::integer AS count FROM lab_registrations WHERE workshop_id = ${id}`; assertEquals(row.seats_remaining, 0); assertEquals(count.count, 1); }) ); } finally { await sql.end(); }});Run from practice/:
docker compose -p wsd-practice-test -f compose.yaml -f compose.test.yaml run --build --rm api deno test --cached-only --allow-env --allow-net tests/workshop-service_test.tsA passing run is evidence for the execution observed. Promise.allSettled does
not force a particular order of database reads and writes, so a passing test alone
does not prove every possible interleaving is safe.
A concurrent checkout test
0 / 5 points
A test uses a pool with several connections, starts two checkout
promises, waits with Promise.allSettled, and observes one stored active
loan. Select every conclusion supported by that setup and observation.
The next chapter extends the workshop with a deadline. A request may wait for a lock, so its arrival time and the time of its protected decision can differ.
Check Your Understanding
- Why can asynchronous requests race even in a single-threaded JavaScript process?
- Why does putting a read and a write inside a transaction not necessarily protect a decision made from that read?
- Which row should competing registration transactions lock before checking remaining seats, and why must they check after acquiring the lock?
- Why must every relevant mutation follow the same lock protocol?
- What does
Promise.allSettledhelp with, and what interleaving does it not guarantee?