Junior Programmer study materials
Junior Programmer study resources

The discount that leaks between customers

Junior Programmer · Put your knowledge to work in context.

All case studies

The discount that leaks between customers

Marco is adding a discount feature to a checkout library used by a single-process web app. He writes def apply_discount(total, percent, rules=[]), which appends a human-readable 'discount applied' entry to rules and returns the new total; rules is used as an audit trail. Separately, a module-level dict _seen_customers = {} records which customer IDs have already received a welcome discount so it is not granted twice. The test suite has 22 tests. Each test passes when run on its own. Run together, test_first_order_gets_discount fails and test_audit_trail_is_empty_at_start fails. In staging, the audit line printed for the second customer of the day already contains the first customer's entry. Marco's lead imposes three constraints: the fix must be small, the public signature must keep working for the 6 existing callers, and the service must stay correct when it later runs with 4 worker threads. Refresh and redeploy happen in the same process.

1. Explain the mechanism behind both failures and give fixes that satisfy the '6 existing callers' constraint.

Read the worked answer

Both are shared-state bugs, but of different kinds.

rules=[] is evaluated once, when the def statement runs, not per call. Every caller that omits the argument appends to the same list object, so customer 2's audit trail starts with customer 1's line, and a test that expects an empty trail fails only if an earlier test already called the function. That is why the suite is green test-by-test and red as a whole: the test order supplies the shared history.

Fix without breaking callers: change the default to None and create the list inside the function.

def apply_discount(total, percent, rules=None):
    if rules is None:
        rules = []
    ...

Every existing call apply_discount(t, 10) still works, and apply_discount(t, 10, my_list) still mutates the caller's list, which is probably intended for an audit trail. The same pattern applies to any mutable default: dicts, sets, and objects with internal state. Do not 'fix' it by clearing the list at the top of the function; that destroys data the caller passed in and merely hides the sharing.

2. Evaluate `_seen_customers` under the lead's 4-thread requirement, and state what has to change when the app scales to 4 processes.

Read the worked answer

Two separate problems.

Threads: the check-then-act sequence is not atomic. Two threads can both read 'customer 441 not in _seen_customers', both decide to grant the welcome discount, and both write. Individual dict operations are protected in CPython, but the read-decide-write sequence is not, so the guarantee 'never granted twice' is broken under concurrency regardless of GIL behaviour. A lock around the check and the insert fixes correctness; a lock around the whole request does not scale.

Processes: even with a perfect lock, a module-level dict is per-process memory. With 4 workers, worker A records customer 441 and worker B, serving the next request from that customer, has an empty dict and grants the discount again. Locks cannot fix this; the state has to be external, e.g. a unique key in the database or Redis (SETNX-style insert) so the 'first one wins' decision is atomic across workers.

Judgement: if duplicate welcome discounts are cheap and rare, it may be acceptable to scope the dict per process and accept small leakage; if they are billable, the shared store is mandatory. Either way, document the scope explicitly rather than assuming the dict is global.

3. Beyond the `None` default, what signature or module-design change would stop this class of bug recurring, and what would it cost the 6 callers?

Read the worked answer

Make the mutation explicit and make accidental sharing hard.

  1. Return a new value rather than mutating an argument. apply_discount(total, percent) returning (new_total, entry) removes the shared list entirely. The typical caller becomes two lines and the audit list is owned by whoever collects the results.
  2. If the argument must remain, make it keyword-only (def apply_discount(total, percent, *, rules=None)) so a future positional mistake shows up as a TypeError instead of silently passing the wrong list.
  3. Keep module-level state to constants only. Anything that changes per request belongs in a class instance, a request-scoped object, or the database. _seen_customers is exactly the kind of name that reads as a harmless cache and behaves as a global variable.

Cost: callers that relied on the mutation keep working under option 2; option 1 is a breaking change for all 6, so it needs a deprecation path (add apply_discount_v2, migrate, delete the old function). Given the lead's 'keep the signature' constraint, option 2 plus the None default is the right first step, with option 1 scheduled as a follow-up rather than smuggled into a hotfix.