Debug a codebase then propose long-term fixes

0
Remix

You inherit a small order service: an inventory that holds stock for orders, a pricing module, and the order service that ties them together. Support tickets say stock "disappears", some order totals are a cent off, page 1 of the order list skips orders, and a cart hold sometimes never expires. Find and fix the defects, add tests that would have caught them, then write up the structural changes that stop this class of bug coming back. You have about 60 minutes and may run code.

Prices are integer cents. A discount is rounded half up to the cent. Pages are numbered from 1.

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."""
        held = {}
        for sku, qty in items.items():
            if self.stock.get(sku, 0) < qty:
                raise OutOfStock(sku)
            self.stock[sku] -= qty
            held[sku] = qty
        self.reserved[order_id] = held

    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)
    return int(subtotal * (1 - discount_pct / 100))


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
            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=[]):
        """lines: [(sku, qty, price_cents)]. Reserve stock, charge, record."""
        tags.append("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)
        self.payments.charge(order_id, total)
        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=time.time()):
        """Drop pending holds older than max_age_s and put their stock back."""
        for order_id, order in self.orders.items():
            if order["status"] == "pending" and now - order["created_at"] > max_age_s:
                self.inventory.release(order_id)
                del self.orders[order_id]

    def list_orders(self, page, page_size):
        """Order ids, sorted; pages are numbered from 1."""
        ids = sorted(self.orders)
        start = page * page_size
        return ids[start:start + page_size]
CursorPractical coding

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.
Interviewer expectations for every level ↓

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.

  1. Read the tickets as hypotheses (3 min). Each symptom points at a module. "Stock disappears" is a conservation bug in Inventory or 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 in expire_holds.
  2. 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.
  3. 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.
  4. 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.
  5. 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 / 100 and float(.
  6. 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

  1. "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.
  2. "How would you find bug 1 in production without the code?" Graph stock + held per SKU against received inventory. A slow downward drift that matches failed-order counts points straight at the error path.
  3. "Make place safe to retry." Accept an idempotency key. Store key → order_id when the order is recorded; a retry with the same key returns the stored order instead of reserving again.
  4. "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 UPDATE for 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.