Prevent overselling and write skew with the right transaction boundary
Application and assignment
A store has one unit left. Ana and Ben both press Reserve. The application must create one reservation and leave stock at zero. Seeing stock=0 afterward is not enough, because two reservations could have been accepted for that one unit.
Use two database sessions to make the conflicting operations happen in a known order. Begin with the stock row, then handle the on-call rule spanning two rows. The supplied SQL and runner use a real local PostgreSQL database. They are separate from the reading-list starter’s SQLite database.
Contract and starting evidence
Constructed candidate brief: “Ana and Ben each reserve the last unit. Both
read available=1 before either writes. Show the exact SQL and expected rows.
Then protect a rule spanning two doctors: at least one must remain on call.
Finally, explain a query plan for a tiny owner and a dominant owner.”
Prerequisites: keys, constraints, and row ownership,
basic SELECT/UPDATE, and two independent database sessions. Attempt the
candidate schedules first. Assessor notes and
the executable reference (download file, source below) are separate from the brief.
Read the supplied code · run_schedules.py
"""Execute real two-session PostgreSQL schedules. Requires psycopg and LAB_PG_DSN."""
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from retry import transaction_retry
def main():
import psycopg
dsn = os.environ["LAB_PG_DSN"]
def connect():
connection = psycopg.connect(dsn, autocommit=True)
connection.execute("SET search_path=interview_lab")
connection.execute("SET statement_timeout='8s'")
connection.execute("SET lock_timeout='6s'")
return connection
with connect() as admin:
admin.execute(Path(__file__).with_name("schema.sql").read_text())
with connect() as a, connect() as b:
# Lost update: both application reads see 1, then write the literal 0.
a.execute("BEGIN")
b.execute("BEGIN")
assert a.execute("SELECT available FROM stock WHERE id=1").fetchone()[0] == 1
assert b.execute("SELECT available FROM stock WHERE id=1").fetchone()[0] == 1
for conn, buyer in ((a, "a"), (b, "b")):
conn.execute("UPDATE stock SET available=0 WHERE id=1")
conn.execute("INSERT INTO reservations VALUES (%s,1)", (buyer,))
conn.execute("COMMIT")
assert admin.execute("SELECT count(*) FROM reservations").fetchone()[0] == 2
print("FAILURE reproduced: stock=0, reservations=2 for initial stock=1")
admin.execute("TRUNCATE reservations")
admin.execute("UPDATE stock SET available=1")
a.execute("BEGIN")
b.execute("BEGIN")
assert a.execute("UPDATE stock SET available=available-1 WHERE id=1 AND available>0 RETURNING id").fetchone() == (1,)
a.execute("INSERT INTO reservations VALUES ('a',1)")
with ThreadPoolExecutor(max_workers=1) as pool:
waiting = pool.submit(b.execute, "UPDATE stock SET available=available-1 WHERE id=1 AND available>0 RETURNING id")
a.execute("COMMIT")
assert waiting.result().fetchone() is None
b.execute("COMMIT")
assert admin.execute("SELECT count(*) FROM reservations").fetchone()[0] == 1
assert admin.execute("SELECT available FROM stock WHERE id=1").fetchone()[0] == 0
print("FIX passed: conditional decrement admits one reservation")
# Write skew under snapshot isolation: disjoint writes evade row conflict.
for conn in (a, b):
conn.execute("BEGIN ISOLATION LEVEL REPEATABLE READ")
assert conn.execute("SELECT count(*) FROM doctors WHERE on_call").fetchone()[0] == 2
a.execute("UPDATE doctors SET on_call=false WHERE id=1")
b.execute("UPDATE doctors SET on_call=false WHERE id=2")
a.execute("COMMIT")
b.execute("COMMIT")
assert admin.execute("SELECT count(*) FROM doctors WHERE on_call").fetchone()[0] == 0
print("FAILURE reproduced: repeatable-read write skew leaves zero doctors")
admin.execute("UPDATE doctors SET on_call=true")
for conn in (a, b):
conn.execute("BEGIN ISOLATION LEVEL SERIALIZABLE")
assert conn.execute("SELECT count(*) FROM doctors WHERE on_call").fetchone()[0] == 2
a.execute("UPDATE doctors SET on_call=false WHERE id=1")
b.execute("UPDATE doctors SET on_call=false WHERE id=2")
failures = []
for conn, doctor in ((a, 1), (b, 2)):
try:
conn.execute("COMMIT")
except psycopg.Error as error:
conn.execute("ROLLBACK")
assert error.sqlstate == "40001", error
failures.append(doctor)
assert len(failures) == 1
def retry_off_call():
with connect() as conn:
try:
conn.execute("BEGIN ISOLATION LEVEL SERIALIZABLE")
count = conn.execute("SELECT count(*) FROM doctors WHERE on_call").fetchone()[0]
if count > 1:
conn.execute("UPDATE doctors SET on_call=false WHERE id=%s", (failures[0],))
conn.execute("COMMIT")
return count
except Exception:
conn.execute("ROLLBACK")
raise
assert transaction_retry(retry_off_call) == 1
assert admin.execute("SELECT count(*) FROM doctors WHERE on_call").fetchone()[0] == 1
print("FIX passed: serialization abort plus complete transaction retry preserves one doctor")
# Deadlock: opposite lock order. Victim rolls back before retry.
a.execute("BEGIN")
b.execute("BEGIN")
a.execute("UPDATE counters SET hits=hits+1 WHERE id=1")
b.execute("UPDATE counters SET hits=hits+1 WHERE id=2")
def finish(conn, second_id):
try:
conn.execute("UPDATE counters SET hits=hits+1 WHERE id=%s", (second_id,))
conn.execute("COMMIT")
return "ok"
except psycopg.Error as error:
conn.execute("ROLLBACK")
return error.sqlstate
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(finish, a, 2)
second = pool.submit(finish, b, 1)
outcomes = sorted((first.result(), second.result()))
assert outcomes == ["40P01", "ok"], outcomes
assert admin.execute("SELECT hits FROM counters ORDER BY id").fetchall() == [(1,), (1,)]
print("FAILURE reproduced: deadlock 40P01; victim transaction rolled back")
def ordered_transaction():
with connect() as conn:
try:
conn.execute("BEGIN")
conn.execute("SELECT id FROM counters ORDER BY id FOR UPDATE").fetchall()
conn.execute("UPDATE counters SET hits=hits+1")
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
transaction_retry(ordered_transaction)
assert admin.execute("SELECT hits FROM counters ORDER BY id").fetchall() == [(2,), (2,)]
admin.execute("UPDATE counters SET hits=0")
with ThreadPoolExecutor(max_workers=2) as pool:
one = pool.submit(transaction_retry, ordered_transaction)
two = pool.submit(transaction_retry, ordered_transaction)
one.result()
two.result()
assert admin.execute("SELECT hits FROM counters ORDER BY id").fetchall() == [(2,), (2,)]
print("FIX passed: ordered locks plus bounded whole-transaction retry")
if __name__ == "__main__":
main()
| Input | Baseline failure | Required fixed value |
|---|---|---|
| Stock 1; two buyers read 1 then write literal 0 | 2 reservations, stock 0 | 1 reservation, stock 0 |
| Two on-call doctors; each reads 2 and turns itself off | 0 on call at repeatable read | At least 1 after every successful commit |
| A locks row 1 then 2; B locks 2 then 1 | Deadlock abort, SQLSTATE 40P01 |
Whole transaction retried; each logical increment happens once |
| 200 then 100,000 bookmark rows | Same plan may not remain cheapest | Correct values and an explained measured plan, no mandated timing ratio |
All sizes are synthetic. This lab targets PostgreSQL 18 semantics, not SQLite
isolation. BEGIN gives atomicity; isolation and write predicates decide whether
racing transactions can preserve the business rule.
Baseline: correctness lives in the application gap
Name the invariant before fixing the statement: available + reservations = 1
for this reset fixture. A nonnegative check alone misses the oversell because
both stale writes store zero.
Move the decision to the protected boundary
- State what one row asserts and encode row-local constraints and foreign keys.
- Interleave two sessions deliberately; label the snapshot each read observes.
- For stock, atomically decrement only if
available > 0, inspectRETURNING, and insert the reservation in the same transaction only when a row returned. - For a rule across rows, a lock on only the changed row is insufficient. Lock a shared guard row in a defined order, or use serializable transactions with a bounded whole-transaction retry. The reference uses serializable isolation.
- On
40001or40P01, roll back and rerun the full read/decide/write sequence. Retrying the final statement repeats a decision made from an invalid snapshot. Keep external effects outside the retry callback, using committed outbox intent.
Local setup and commands
No AWS deployment is required. You need a disposable PostgreSQL 18 database,
psql, Python 3.11+, and psycopg 3. These commands reset only the
interview_lab schema inside the database named by LAB_PG_DSN. Do not point
them at an existing application database. The optional Docker command starts a
local server; using your own local installation is equally valid.
docker run --name interview-pg --rm -e POSTGRES_PASSWORD=lab-only -e POSTGRES_DB=interview_lab -p 127.0.0.1:55432:5432 -d postgres:18
export LAB_PG_DSN='postgresql://postgres:lab-only@127.0.0.1:55432/interview_lab'
docker exec interview-pg pg_isready -U postgres -d interview_lab
python -m venv /tmp/interview-pg-venv
/tmp/interview-pg-venv/bin/pip install 'psycopg[binary]>=3,<4'
/tmp/interview-pg-venv/bin/python curriculum/02-applications/02-databases/labs/postgresql/run_schedules.py
psql "$LAB_PG_DSN" -X -v ON_ERROR_STOP=1 -f curriculum/02-applications/02-databases/labs/postgresql/plans.sql > /tmp/interview-pg-plans.txt
Wait for pg_isready to report accepting connections before the runner. The
server's deadlock detection interval must be shorter than the runner's six-second
lock timeout; the PostgreSQL default meets that assumption. A lock timeout
(55P03) is deliberately not silently relabeled a deadlock.
If you have no host psql, run the plan file through the container instead:
docker exec -i interview-pg psql -U postgres -d interview_lab -X -v ON_ERROR_STOP=1 < curriculum/02-applications/02-databases/labs/postgresql/plans.sql > /tmp/interview-pg-plans.txt
The runner prints three FAILURE reproduced and three FIX passed lines. A
failed assertion stops execution. PostgreSQL/Docker were unavailable in the
authoring environment: these database commands were not runtime-verified
there. The Python sources were syntax checked, and the standard-library retry
tests can run without PostgreSQL:
python -m unittest discover -s curriculum/02-applications/02-databases/labs/postgresql -p 'test_*.py' -v
Follow-up: the rule spans rows
Predict whether repeatable read prevents two doctors from each observing two on-call rows and updating different rows. Its stable snapshots do not themselves prevent this write skew. Serializable mode may abort one transaction instead.
Senior: show the rejected SQLSTATE and a full retry that changes the decision. Lead: choose a contention/retry budget, ownership of the cross-row invariant, and a compatibility plan for old clients that do not follow the new rule.
Follow-up: the index loses
An ordinary PostgreSQL B-tree stores ordered keys and row locations. Heap pages hold MVCC row versions; primary-key ordering does not continuously cluster the heap. Inserts maintain indexes; a HOT-eligible update can reuse ordinary index entries when indexed values are unchanged and the old heap page has room.
The plan script seeds 200 uniform rows, 100,000 uniform rows, then a skew where
owner 1 has 90,100 rows and owner 2 has 100. It compares the small query before
and after an index, a large query with and without it, selective and broad scans,
and an ordered LIMIT 20. On the broad query, a sequential scan can avoid many
scattered heap accesses. On the ordered limited query, the index can stop early.
Do not require a particular plan node across hardware, cache state, or planner
settings. Never use enable_seqscan=off as evidence that an index wins naturally.
For each plan record estimated/actual rows, returned rows, heap/index buffers,
sort method, execution time, and warm/cold-cache assumptions. Inspect stale
statistics, skew, correlations, and generic prepared plans when estimates differ.
An EXPLAIN ANALYZE executes the query; use care for modifying statements.
CLUSTER requires an exclusive lock and temporarily reorders the heap; it does
not maintain future writes in index order. This exercise observes HOT statistics
without asserting an exact count because page space and statistics timing vary.
Senior acceptance: reproduce all three failing schedules and fixed invariants; explain measured small/large/skewed plans. Lead: budget index creation, locks, vacuum/bloat, connection pressure, and migration compatibility. Supply the plan output and server version as evidence; green Python-only retry tests cannot substitute for database execution.
Cleanup the optional local container:
docker stop interview-pg
Technical references, undated live documentation accessed 2026-09-22: PostgreSQL 18 isolation, explicit locking, EXPLAIN, CLUSTER, HOT. These support semantics, not dated evidence that a company asks this constructed exercise.