Server-Side Applications & Databases

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.

Course diagram
Figure 1. Both requests act on the same earlier observation, allowing two registrations for one seat.

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:up
CREATE 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:down
DROP 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:

Terminal window
docker compose run --rm database-migrations
docker compose -p wsd-practice-test -f compose.yaml -f compose.test.yaml down
docker compose -p wsd-practice-test -f compose.yaml -f compose.test.yaml up -d --build database database-migrations

Lock 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 UPDATE statement 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.ts
  • api/src/database.ts
  • api/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/:

Terminal window
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.ts

A 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

  1. Why can asynchronous requests race even in a single-threaded JavaScript process?
  2. Why does putting a read and a write inside a transaction not necessarily protect a decision made from that read?
  3. Which row should competing registration transactions lock before checking remaining seats, and why must they check after acquiring the lock?
  4. Why must every relevant mutation follow the same lock protocol?
  5. What does Promise.allSettled help with, and what interleaving does it not guarantee?