What L5 looks like
- Finds bugs 1–8, including the frozen default clock and the ignored keyword arguments, and ranks them by customer impact.
- Writes the stock conservation invariant and a randomized test for it, and uses it to find the reused-id leak (bug 9).
- Proposes the guards that stop recurrence: integer money, lint rules B006/B008, an injected clock, compensation on failure.
How to run the round
This round grades how you debug, not how fast you type. The interviewer watches the order you work in, so say it out loud: reproduce, isolate, fix, prove, then generalize.
-
Read the tickets as hypotheses (3 min). Each symptom points at a module. "Stock disappears" is a conservation bug in
Inventoryor in a caller that forgets to give stock back. "A cent off" is money arithmetic. "Page 1 skips orders" is an index. "A hold never expires" is time or iteration inexpire_holds. -
Write the invariant before the fix (5 min). The one that matters most here: for every SKU,
available + held by open orders == starting stock, and the set of orders that hold stock equals the set of orders the service knows. One random-operations test that checks this after every step finds three of the bugs below by itself. - Reproduce each symptom with a failing test (20 min). One small test per ticket, red before you touch the code. A fix without a red test first is a guess.
- Fix the smallest thing that turns it green (15 min). Keep each fix to the root cause. Don't refactor while the tests are red.
-
Sweep for the same class of bug (5 min). After you find a mutable default, grep for every
=[],={}and=time.in a signature. After a float in money, grep for/ 100andfloat(. - Write up the long-term fixes (10 min). Name the class of bug, the guard that makes it impossible (a type, a lint rule, a test), and what it costs.
Read the code once top to bottom before running anything. Most of these bugs are visible on a careful read; the tests then confirm them.
Planted bugs
| # | Where | Bug | Symptom | Severity | Fix |
|---|---|---|---|---|---|
| 1 | Inventory.reserve |
Decrements SKUs one at a time and raises part way through | A failed order keeps the stock it already took: "stock disappears" | High | Check every SKU first, then decrement (all or nothing) |
| 2 | OrderService.place |
Charges after reserving with no compensation | A declined card leaves the stock held forever | High |
try/except: release the reservation, re-raise |
| 3 | order_total |
Float math, then int() truncates |
1000¢ at 7% off gives 929¢ (exact: 930¢); 10¢ at 1% gives 9¢ | High | Integer cents: discount = (subtotal * pct + 50) // 100
|
| 4 | ttl_cache |
Cache key is args only; keyword arguments are ignored |
price("mug", currency="EUR") returns the cached USD price |
High | Key on (args, sorted kwargs)
|
| 5 | OrderService.place |
Mutable default tags=[]
|
Every order made without tags shares one list; it grows ["web", "web", …]
|
Medium |
tags=None, build a new list per call |
| 6 | expire_holds |
Default now=time.time() is evaluated once, at import |
On a long-running server, now is the boot time, so holds never look old |
High |
now=None, read the clock inside |
| 7 | expire_holds |
Deletes from self.orders while iterating it |
RuntimeError: dictionary changed size during iteration once anything expires |
High | Collect expired ids first, then delete |
| 8 | list_orders |
start = page * page_size with pages numbered from 1 |
Page 1 starts at the second page; the first page_size orders never show |
Medium |
start = (page - 1) * page_size; reject page < 1
|
| 9 | Inventory.reserve |
Reusing an order_id overwrites its earlier hold |
The first hold's stock can never be released | Low | Refuse a second reserve for the same id |
Bug 9 is not in the tickets. Finding it from the invariant test, and saying why it matters (an idempotent retry of hold would leak stock), is a strong signal.
Two of these hide each other. Bug 7 raises as soon as one hold expires, but on a real server bug 6 means nothing ever expires, so bug 7 never fires in production. Fixing bug 6 alone turns a silent leak into a crash. That is why you run the full suite after each fix, not only the test you were working on.
The fixes
Each fix is a few lines. Integer cents and half-up rounding go through (x + 50) // 100, which is exact for non-negative integers.
class OutOfStock(Exception):
pass
class Inventory:
def __init__(self, stock=None):
self.stock = dict(stock or {}) # sku -> units on the shelf
self.reserved = {} # order_id -> {sku: qty}
def available(self, sku):
return self.stock.get(sku, 0)
def reserve(self, order_id, items):
"""Hold every (sku, qty) in `items` for one order, or none of them."""
if order_id in self.reserved:
raise ValueError(f"order {order_id} already holds stock")
short = [sku for sku, qty in items.items() if self.stock.get(sku, 0) < qty]
if short:
raise OutOfStock(short[0]) # nothing has been touched yet
for sku, qty in items.items():
self.stock[sku] -= qty
self.reserved[order_id] = dict(items)
def release(self, order_id):
for sku, qty in self.reserved.pop(order_id, {}).items():
self.stock[sku] += qty
import functools
import time
def order_total(lines, discount_pct=0):
"""Total in integer cents. `lines` is [(price_cents, qty)].
The discount is rounded half up to the cent, then taken off the subtotal."""
subtotal = sum(price * qty for price, qty in lines)
discount = (subtotal * discount_pct + 50) // 100 # integer math, half up
return subtotal - discount
def ttl_cache(seconds, clock=time.monotonic):
"""Memoize a function's results for `seconds`."""
def decorate(fn):
store = {}
@functools.wraps(fn)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
hit = store.get(key)
if hit is not None and clock() - hit[1] < seconds:
return hit[0]
value = fn(*args, **kwargs)
store[key] = (value, clock())
return value
return wrapper
return decorate
import time
from inventory import Inventory
from pricing import order_total
class OrderService:
def __init__(self, inventory: Inventory, payments):
self.inventory = inventory
self.payments = payments # has charge(order_id, cents); may raise
self.orders = {}
def place(self, order_id, lines, discount_pct=0, tags=None):
"""lines: [(sku, qty, price_cents)]. Reserve stock, charge, record."""
tags = [*(tags or []), "web"]
self.inventory.reserve(order_id, {sku: qty for sku, qty, _ in lines})
total = order_total([(price, qty) for _, qty, price in lines], discount_pct)
try:
self.payments.charge(order_id, total)
except Exception:
self.inventory.release(order_id) # compensate, then let the caller see the error
raise
self.orders[order_id] = {"status": "paid", "total": total, "tags": tags, "created_at": time.time()}
return self.orders[order_id]
def hold(self, order_id, items, now=None):
"""Reserve stock for a cart without paying yet."""
self.inventory.reserve(order_id, items)
self.orders[order_id] = {"status": "pending", "tags": [], "created_at": time.time() if now is None else now}
def expire_holds(self, max_age_s, now=None):
"""Drop pending holds older than max_age_s and put their stock back."""
now = time.time() if now is None else now
expired = [
order_id
for order_id, order in self.orders.items()
if order["status"] == "pending" and now - order["created_at"] > max_age_s
]
for order_id in expired:
self.inventory.release(order_id)
del self.orders[order_id]
return expired
def list_orders(self, page, page_size):
"""Order ids, sorted; pages are numbered from 1."""
if page < 1:
raise ValueError("pages start at 1")
ids = sorted(self.orders)
start = (page - 1) * page_size
return ids[start:start + page_size]
Worked numbers for bug 3: 1 - 7/100 is 0.9299999999999999 in binary floating point, so 1000 * 0.93 is 929.9999999999999 and int() truncates it to 929. The integer version computes (1000 * 7 + 50) // 100 = 70, and 1000 - 70 = 930. For 10¢ at 1%, the exact discount is 0.1¢, which rounds to 0, so the total stays 10¢; the float version charges 9¢.
Tests
Level 1 covers the four tickets. Level 2 covers the cache and the hold expiry. Level 3 is the invariant test: random place, hold and expire calls with a card that declines 30% of the time, checking stock conservation after every step.
from inventory import Inventory, OutOfStock
from orders import OrderService
from pricing import order_total
class Payments:
def __init__(self, fail=False):
self.fail, self.charges = fail, []
def charge(self, order_id, cents):
if self.fail:
raise RuntimeError("card declined")
self.charges.append((order_id, cents))
def test_failed_reservation_holds_nothing():
inv = Inventory({"mug": 5, "tee": 1})
try:
inv.reserve("o1", {"mug": 2, "tee": 3})
assert False, "expected OutOfStock"
except OutOfStock:
pass
assert inv.available("mug") == 5
assert inv.available("tee") == 1
def test_declined_card_returns_the_stock():
inv = Inventory({"mug": 5})
svc = OrderService(inv, Payments(fail=True))
try:
svc.place("o1", [("mug", 2, 1200)])
assert False, "expected the charge error"
except RuntimeError:
pass
assert inv.available("mug") == 5
assert "o1" not in svc.orders
def test_orders_do_not_share_tags():
svc = OrderService(Inventory({"mug": 5}), Payments())
a = svc.place("o1", [("mug", 1, 1200)])
b = svc.place("o2", [("mug", 1, 1200)])
assert a["tags"] == ["web"]
assert b["tags"] == ["web"]
def test_first_page_is_page_one():
svc = OrderService(Inventory({"mug": 9}), Payments())
for i in range(5):
svc.place(f"o{i}", [("mug", 1, 100)])
assert svc.list_orders(1, 2) == ["o0", "o1"]
assert svc.list_orders(3, 2) == ["o4"]
def test_discount_is_exact_to_the_cent():
assert order_total([(1000, 1)], 7) == 930
assert order_total([(2500, 1)], 31) == 1725
assert order_total([(10, 1)], 1) == 10 # 0.1 cent rounds to no discount
assert order_total([(50, 1)], 5) == 47 # 2.5 cents rounds half up to 3
import time
from inventory import Inventory
from orders import OrderService
from pricing import ttl_cache
def test_cache_key_includes_keyword_arguments():
calls = []
@ttl_cache(60)
def price(sku, currency="USD"):
calls.append((sku, currency))
return {"USD": 1200, "EUR": 1100}[currency]
assert price("mug") == 1200
assert price("mug", currency="EUR") == 1100
assert price("mug", currency="EUR") == 1100
assert len(calls) == 2
def test_cache_entries_expire():
now = [0.0]
@ttl_cache(10, clock=lambda: now[0])
def stamp(x):
return (x, now[0])
assert stamp(1) == (1, 0.0)
now[0] = 9.0
assert stamp(1) == (1, 0.0)
now[0] = 10.0
assert stamp(1) == (1, 10.0)
def test_expire_holds_drops_several_old_holds():
inv = Inventory({"mug": 10})
svc = OrderService(inv, payments=None)
svc.hold("a", {"mug": 2}, now=100)
svc.hold("b", {"mug": 3}, now=110)
svc.hold("c", {"mug": 1}, now=500)
svc.expire_holds(max_age_s=60, now=400)
assert sorted(svc.orders) == ["c"]
assert inv.available("mug") == 9
def test_expire_holds_uses_the_current_time():
real_time = time.time
later = real_time() + 7200 # the service has been up for two hours
time.time = lambda: later
try:
inv = Inventory({"mug": 10})
svc = OrderService(inv, payments=None)
svc.hold("a", {"mug": 2}, now=later - 120) # two minutes old
svc.expire_holds(max_age_s=60)
assert "a" not in svc.orders
assert inv.available("mug") == 10
finally:
time.time = real_time
import random
from inventory import Inventory, OutOfStock
from orders import OrderService
class FlakyPayments:
def __init__(self, rng):
self.rng = rng
def charge(self, order_id, cents):
if self.rng.random() < 0.3:
raise RuntimeError("declined")
def test_stock_is_conserved_under_random_operations():
rng = random.Random(7)
start = {"mug": 20, "tee": 15, "cap": 10}
inv = Inventory(start)
svc = OrderService(inv, FlakyPayments(rng))
for step in range(2000):
oid = f"o{step}"
items = {sku: rng.randint(1, 4) for sku in rng.sample(sorted(start), rng.randint(1, 3))}
action = rng.random()
try:
if action < 0.4:
svc.place(oid, [(sku, qty, 500) for sku, qty in items.items()])
elif action < 0.8:
svc.hold(oid, items, now=step)
else:
svc.expire_holds(max_age_s=50, now=step)
except (OutOfStock, RuntimeError):
pass
for sku, total in start.items():
held = sum(r.get(sku, 0) for r in inv.reserved.values())
assert inv.available(sku) >= 0
assert inv.available(sku) + held == total, (step, sku)
assert set(inv.reserved) == set(svc.orders)
On the starter code, level 1 fails 5 of 5, level 2 fails 3 of 4, and level 3 fails. With the fixes, all pass.
Long-term fixes
Patching nine lines is the first half. The write-up should name each class of bug and the cheapest guard that makes it impossible, not less likely.
| Class of bug | Bugs | Structural fix | Cost |
|---|---|---|---|
| Partial updates on failure | 1, 2, 9 | Make "reserve, charge, record" one unit of work. In-process: validate first, mutate last, compensate on error. With a database: one transaction for the stock rows and the order row, plus an idempotency key on place so a retried request finds the first result. |
Some code reshuffling; a payments call can't sit inside a DB transaction, so the charge becomes a separate step with a pending state |
| Money as float | 3 | A Money value type (integer minor units plus currency) with the rounding rule in one place; float money fails type checks |
A small type and a migration of call sites |
| Hidden global state | 4, 5, 6 | Turn on lint rules: ruff B006 (mutable default) and B008 (call in a default) catch 5 and 6 in CI. Pass a clock into the service instead of calling time.time() deep inside. Use a tested cache (cachetools.TTLCache) or key with inspect.signature(fn).bind(...). |
Near zero; the clock injection also makes the code testable |
| Mutation while iterating | 7 | Lint (bugbear's loop-mutation check, B909) plus a habit: build the list of changes, then apply | Zero |
| Off-by-one in pagination | 8 | Cursor pagination (after=<last id>) instead of page numbers: no arithmetic, and stable when rows are added |
An API change for clients |
| Missing invariants | all | Keep the property test from level 3 in CI. Add a periodic reconciliation job in production that compares stock plus open holds with the starting count and alerts on drift. | A few seconds of CI time; one job |
Concurrency is the next bug. reserve reads stock, then writes it. Two threads can both see 1 unit and both take it. In one process, put a threading.Lock around reserve and release. With a database, use UPDATE stock SET qty = qty - $n WHERE sku = $s AND qty >= $n and check the affected row count, which makes the check and the write one atomic step.
Follow-ups
- "Which fix would you ship first?" Bug 7 and bug 6 together (holds stuck, then a crash), then 1 and 2 (stock leaks are customer-visible and compound). The cent rounding is real money but small and easy to reconcile later.
-
"How would you find bug 1 in production without the code?" Graph
stock + heldper SKU against received inventory. A slow downward drift that matches failed-order counts points straight at the error path. -
"Make
placesafe to retry." Accept an idempotency key. Storekey → order_idwhen the order is recorded; a retry with the same key returns the stored order instead of reserving again. - "The cache is shared across threads." Two concurrent misses both call the function: harmless here, but for an expensive call use a per-key lock or a future so only one caller computes it.
Interviewer expectations
Mid-level (L4)
- Finds the bugs the tickets point at (1, 3, 5, 7, 8) and fixes each at the root, with a failing test written first.
- Explains the mutable default and the float rounding correctly, with the worked numbers.
- Runs the whole suite after each fix and notices when one fix exposes another.
Senior (L5)
- Finds bugs 1–8, including the frozen default clock and the ignored keyword arguments, and ranks them by customer impact.
- Writes the stock conservation invariant and a randomized test for it, and uses it to find the reused-id leak (bug 9).
- Proposes the guards that stop recurrence: integer money, lint rules B006/B008, an injected clock, compensation on failure.
Staff (L6+)
- Turns the fixes into a short design note: one unit of work for reserve-charge-record, idempotency keys for retries, the atomic conditional
UPDATEfor concurrency, cursor pagination. - Adds production guards: a reconciliation job with an alert, and metrics on failed charges and expired holds that would have shown these bugs within a day.
- Explains what each guard costs and which to do this week versus this quarter, and how to roll out the money type without a flag day.
Make it yours
Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups. Practice it with the 3-level tests, run in your browser.
