Interview Question Banks

Python Interview Questions · questions with model answers

Senior 5–8 years

229 questions · every code snippet and expected output was run and checked on Python 3.12.

In progress: 229 of 241 questions written so far. More are being added.

Easy (E)

SR-E-O01 · Fire-and-forget submit() in an SMS notification batch

Easy · Output · Senior (5–8 years) · concurrent.futures, ThreadPoolExecutor, exception propagation, observability · 4 min

Question: An OTP/notification service sends SMS through a gateway using a thread pool. On-call says "the batch job reports success but some customers never got their SMS, and there's nothing in the logs." What does this print, and where did the error go?

from concurrent.futures import ThreadPoolExecutor


def send_sms(phone: str) -> str:
    if phone.endswith("0"):
        raise ConnectionError(f"gateway refused {phone}")
    return f"sent {phone}"


phones = ["9845012340", "9845012341", "9845012342"]
with ThreadPoolExecutor(max_workers=2) as pool:
    futures = [pool.submit(send_sms, p) for p in phones]
print("batch finished")

print([f.done() for f in futures])
print([type(f.exception()).__name__ for f in futures])
print(futures[1].result())
try:
    futures[0].result()
except ConnectionError as exc:
    print("caught:", exc)

Expected output:

batch finished
[True, True, True]
['ConnectionError', 'NoneType', 'NoneType']
sent 9845012341
caught: gateway refused 9845012340

Explanation:

pool.submit() returns a Future. If the callable raises, the worker thread catches the exception and stores it on the future; nothing is printed, logged or re-raised in the submitting thread. The exception surfaces only when someone calls future.result() (which re-raises it) or future.exception() (which returns it).

Leaving the with block calls shutdown(wait=True), so all three futures are done() by the time batch finished prints, and the batch "finishes" without a trace of the failure. exception() returns None for the successful futures, hence NoneType. Only the explicit result() call at the end brings the ConnectionError back into the caller's control flow.

This is the usual cause of "silent" failures in fire-and-forget pools: the code submits work, never inspects the futures, and the errors are garbage-collected along with them. executor.map() behaves differently: it re-raises the first failure when the iterator reaches that item, and you lose the results after it.

Takeaway: a submitted task's failure is data on its future. Every submit() needs a matching place that calls result() (or an add_done_callback that logs and counts failures); otherwise you have no error handling at all.

Follow-ups:

  1. "How would you change this so every failure is logged and counted, without blocking the submitting code on each result?" Attach future.add_done_callback(on_done) where on_done calls fut.exception() and logs with the phone (masked) and a metric such as sms_send_failures_total{reason=...}. Or collect futures and iterate as_completed(futures), calling result() inside try/except per future. A callback runs in the worker thread (or immediately in the caller if the future is already done), so it must be cheap and must not raise.
  2. "Why did the team pick threads rather than processes or asyncio here, and when would you revisit it?" Sending an SMS is I/O-bound: the thread spends its time waiting on the network with the GIL released, so threads give real concurrency with the existing blocking HTTP client. Processes add pickling and startup cost for no gain. asyncio would win at high fan-out (thousands of concurrent sends) with an async HTTP client, because threads cost memory and context switches; revisit when concurrency, not CPU, is the limit.
  3. "The gateway is flaky and you add retries inside send_sms. What else must change?" Retries on a non-idempotent send can deliver duplicate OTPs. Send an idempotency/dedup key the gateway honours (or check delivery status before resending), retry only on retryable errors with backoff and jitter, and cap total time so the pool's threads aren't held for minutes.

Scoring: Strong: Exact output, explains that exceptions are stored on the future and only re-raised by result(), and names a concrete fix (done-callback or as_completed with per-future handling, plus a failure metric). Contrasts with map() re-raising. Acceptable: Gets the output after a moment's thought and knows result() re-raises; needs a prompt for how to make failures observable in production. Red flag: Expects the traceback to print or the with block to raise; or proposes wrapping the with block in try/except as the fix.

SR-E-O02 · Duplicate and missing log lines from logger propagation

Easy · Output · Senior (5–8 years) · logging, logger hierarchy, propagation, observability · 5 min

Question: The platform team configures the root logger at WARNING with one handler. The payments team adds its own handler to payments.refunds so they see INFO in their dashboard. Support now complains of duplicated lines in the central log and "missing" settlement logs. What does this print?

import logging
import sys

root = logging.getLogger()
root.setLevel(logging.WARNING)
root_handler = logging.StreamHandler(sys.stdout)
root_handler.setFormatter(logging.Formatter("ROOT  %(name)s: %(message)s"))
root.addHandler(root_handler)

log = logging.getLogger("payments.refunds")
log.setLevel(logging.INFO)
team_handler = logging.StreamHandler(sys.stdout)
team_handler.setFormatter(logging.Formatter("TEAM  %(levelname)s %(name)s: %(message)s"))
log.addHandler(team_handler)

log.info("refund queued id=R-101")
log.warning("refund retry id=R-101")

log.propagate = False
log.warning("refund failed id=R-101")

upi = logging.getLogger("payments.refunds.upi")
upi.debug("raw bank response")
upi.info("VPA verified")
logging.getLogger("payments").info("settlement batch started")
logging.getLogger("payments").warning("settlement late")

Expected output:

TEAM  INFO payments.refunds: refund queued id=R-101
ROOT  payments.refunds: refund queued id=R-101
TEAM  WARNING payments.refunds: refund retry id=R-101
ROOT  payments.refunds: refund retry id=R-101
TEAM  WARNING payments.refunds: refund failed id=R-101
TEAM  INFO payments.refunds.upi: VPA verified
ROOT  payments: settlement late

Explanation:

Two rules explain every line:

  1. The level check happens once, on the logger you call. A record is created only if it passes the effective level of the logger it was logged on (its own level, or the nearest ancestor's that is set). After that, the record is handed to that logger's handlers and then, while propagate is true, to each ancestor's handlers. Ancestor logger levels are not re-checked; only handler levels are, and these handlers are at NOTSET. That is why the INFO "refund queued" reaches the root handler even though root is at WARNING: two lines per call, and the duplicates in the central log.
  2. propagate = False stops the walk up the tree at that logger. "refund failed" goes only to the team handler and disappears from the central log.

payments.refunds.upi has no level, so its effective level is INFO (inherited from payments.refunds): DEBUG is dropped, INFO is created and handled by the parent's team handler, and the walk stops there because the parent has propagate = False. payments has no level either, so it inherits WARNING from root: "settlement batch started" is dropped at creation (the "missing" log), and "settlement late" reaches the root handler.

Takeaway: in a service, attach handlers in one place (the root, configured at startup via dictConfig) and control verbosity with logger levels; libraries and team modules should only call getLogger(__name__) and never add handlers.

Follow-ups:

  1. "How would you let the payments team see INFO for their loggers in the central sink without duplicates?" Remove their handler and set logging.getLogger('payments').setLevel(logging.INFO) in the central config. The records are created at INFO and propagate once to the root handler. If they need a separate destination, put a second handler on root with a Filter on record.name.startswith('payments'), or route by logger name in the log pipeline instead of in-process.
  2. "What should a library you publish for other teams do about logging?" Use logging.getLogger(__name__), never configure handlers or levels, and add a NullHandler to the package's top-level logger to avoid the 'last resort' handler printing warnings. Configuration belongs to the application.
  3. "Your service logs ~20k lines/s and p99 latency rises when the log disk is slow. What do you change?" Logging handlers run synchronously in the calling thread. Use a QueueHandler on the hot path with a QueueListener writing to the real handler in a background thread (bounded queue, decide drop vs block), log structured JSON to stdout for the collector, and cut volume: sample or drop DEBUG/INFO in hot loops.

Scoring: Strong: All seven lines exact. States that ancestor levels are not consulted during propagation (only handler levels), explains the effective-level inheritance for upi and payments, and recommends handlers-at-root-only as the standard. Acceptable: Spots the duplication and the effect of propagate = False, but expects the root's WARNING level to block the propagated INFO; corrects with a hint about where the level check happens. Red flag: Treats loggers as independent (no hierarchy) or 'fixes' duplicates by setting propagate = False everywhere.

SR-E-O03 · Request IDs in contextvars across asyncio tasks

Easy · Output · Senior (5–8 years) · contextvars, asyncio, tasks, observability, request tracing · 5 min

Question: An async API tags every log line with a request ID stored in a ContextVar, so a single request can be followed through the logs. What does this print?

import asyncio
import contextvars

request_id = contextvars.ContextVar("request_id", default="-")


def log(msg: str) -> None:
    print(f"[{request_id.get()}] {msg}")


async def handle(rid: str, delay: float) -> None:
    request_id.set(rid)
    log("start")
    await asyncio.sleep(delay)
    log("done")


async def main() -> None:
    request_id.set("boot")
    await asyncio.gather(handle("req-A", 0.05), handle("req-B", 0.01))
    log("after gather")
    await handle("req-C", 0)
    log("after direct await")


asyncio.run(main())
log("outside the loop")

Expected output:

[req-A] start
[req-B] start
[req-B] done
[req-A] done
[boot] after gather
[req-C] start
[req-C] done
[req-C] after direct await
[-] outside the loop

Explanation:

Every asyncio.Task runs in its own copy of the context, taken when the task is created. gather() wraps each coroutine in a task, so handle("req-A") and handle("req-B") each set the variable in their own copy. While they interleave at await asyncio.sleep(...), each still sees its own ID ("req-B" finishes first because its sleep is shorter). Their set() calls never touch main's context, so main still sees boot.

Awaiting a coroutine directly does not create a task. await handle("req-C", 0) runs in main's own context, so its set("req-C") is still visible in main afterwards: the ID "leaks" into whatever main logs next.

asyncio.run() itself runs main() as a task in a copied context, so boot never reaches the module level, which still sees the default -.

This is why ContextVar (not a global, not threading.local) is the right tool for request IDs and tenant IDs in async code: a thread-local would be shared by every coroutine on the loop's single thread. Takeaway: a task gets a snapshot of the context at creation; a directly awaited coroutine shares its caller's.

Follow-ups:

  1. "A handler offloads work with loop.run_in_executor(None, fn) and the logs from fn show [-]. Why, and what's the fix?" run_in_executor runs fn in a pool thread with that thread's context, not the caller's. asyncio.to_thread(fn) copies the current context for you (it uses contextvars.copy_context().run). With a raw executor, submit ctx.run, fn where ctx = contextvars.copy_context().
  2. "How would you make middleware set the request ID so it can't leak between requests?" Set it at the start of each request and reset it in finally with the token: token = request_id.set(rid) ... request_id.reset(token). Frameworks usually run each request in its own task, but resetting is cheap insurance for code paths (tests, background loops) that reuse a context. Then a logging.Filter copies request_id.get() onto every record so the formatter or JSON encoder emits it.
  3. "The request fans out to three downstream services. How does the ID reach their logs?" Propagate it on the wire: a header such as X-Request-ID or, better, W3C traceparent via OpenTelemetry, whose context API is itself built on contextvars. The outgoing HTTP client reads the context var and injects the header; the downstream service's middleware extracts it and sets its own context var.

Scoring: Strong: All nine lines exact. Explains task context copying, why the direct await leaks req-C into main, and why asyncio.run keeps boot from the module level. Contrasts with threading.local and mentions to_thread vs run_in_executor. Acceptable: Gets the gather part right, misses the leak from the direct await or the final [-]; understands once tasks vs plain awaits are pointed out. Red flag: Expects the IDs to clobber each other like a global, or suggests a module-level dict keyed by task as the design.

SR-E-O04 · State and pickling across a ProcessPoolExecutor boundary

Easy · Output · Senior (5–8 years) · multiprocessing, ProcessPoolExecutor, pickling, per-process state, CPU-bound work · 5 min

Question: A scoring job moves CPU-heavy feature computation into a process pool. A developer keeps an audit list of everything scored in a module global. What does this print?

import multiprocessing as mp
from concurrent.futures import ProcessPoolExecutor

seen: list[int] = []


def score(batch: list[int]) -> tuple[int, int]:
    seen.extend(batch)
    return sum(batch), len(seen)


if __name__ == "__main__":
    ctx = mp.get_context("spawn")
    with ProcessPoolExecutor(max_workers=1, mp_context=ctx) as pool:
        print(list(pool.map(score, [[1, 2], [3, 4], [5]])))
    print(seen)
    print(score([10]))

    with ProcessPoolExecutor(max_workers=1, mp_context=ctx) as pool:
        future = pool.submit(lambda x: x * 2, 21)
        try:
            future.result()
        except Exception as exc:
            print(type(exc).__name__)

Expected output:

[(3, 2), (7, 4), (5, 5)]
[]
(10, 1)
PicklingError

Explanation:

Processes don't share memory; everything crossing the boundary is pickled. The pool sends each batch to a worker process by pickling it, and pickles the return value back. The worker has its own copy of the module, including its own seen list.

  • With max_workers=1, all three batches go to the same worker, which keeps its state between tasks: seen grows to 2, 4, then 5 items inside the worker. map returns results in input order.
  • The parent's seen is untouched: []. Calling score([10]) in the parent appends to the parent's list, so it reports length 1.
  • A lambda can't be pickled (pickle stores functions by importable name, and <lambda> isn't one), so the submit fails with PicklingError, raised from future.result().

The spawn start method (the default on macOS and Windows; Linux defaults to fork up to 3.13 and forkserver from 3.14) starts a fresh interpreter that imports the main module, which is why the if __name__ == "__main__": guard is required. Spawn is used here so the behaviour is the same on every OS.

Takeaway: in a process pool, task functions must be importable top-level functions, arguments and results must be picklable (and cheap to pickle), and worker globals are per-process caches, not shared state. Results come back as return values.

Follow-ups:

  1. "Each task needs a 400 MB model. How do you avoid loading or sending it per task?" Load it once per worker with ProcessPoolExecutor(initializer=load_model, initargs=(path,)) storing it in a worker global, and send only small inputs (IDs, file paths, or array chunks) per task. With fork on Linux you can also load before creating the pool and rely on copy-on-write, but refcount updates touch pages and gradually copy them, and fork with threads is unsafe; prefer the initializer, or shared memory or memory-mapped arrays for large read-only data.
  2. "When is a process pool the wrong choice even for CPU-heavy work?" When per-task data is large relative to compute (pickling dominates), when tasks are tiny (IPC overhead per task; batch with chunksize), or when the heavy work is already in NumPy/pandas/C code that releases the GIL, where threads or vectorisation get the speedup without copying. Measure first: profile to confirm it's Python-level CPU, then compare wall time.
  3. "A worker process is killed by the OOM killer mid-task. What does the caller see?" The pool becomes broken: pending and future result() calls raise concurrent.futures.process.BrokenProcessPool, and the executor can't be reused. Production code catches it, recreates the pool, and resubmits the affected tasks, which must therefore be idempotent. max_tasks_per_child (3.11+) can recycle workers that leak memory.

Scoring: Strong: Exact output, including that the single worker accumulates state across tasks. Explains pickling of arguments and results, per-process globals, why lambdas fail, and the main guard under spawn. Mentions initializer for per-worker setup. Acceptable: Knows the parent's list stays empty and the lambda fails, but predicts (3, 2), (7, 2), (5, 1) (a fresh worker per task); corrects once told the pool reuses one worker. Red flag: Expects the parent's seen to contain the items, or thinks processes share globals like threads.

SR-E-C01 · A latency-metric context manager that never breaks the request

Easy · Coding · Senior (5–8 years) · observability, metrics, context managers, contextlib, monotonic clocks, testability · 12 min

Problem:

Your team is standardising latency metrics across services. Write timed, used as a context manager or a decorator, that measures how long a block takes and calls emit(metric, duration_ms, tags) exactly once per block.

Requirements: - tags is the caller's keyword tags plus outcome="ok", or outcome="error" and error_type=<exception class name> when the block raises. - The block's exception must propagate unchanged; timing must never swallow it. - Telemetry must never break business logic: if emit itself raises, the block's result or exception is unaffected. - Use a monotonic clock by default (time.perf_counter), injectable for tests. - As a decorator, each call is timed separately.

Signature: @contextmanager def timed(metric: str, emit: Callable[[str, float, dict[str, str]], None], *, clock: Callable[[], float] = time.perf_counter, **tags: str) -> Iterator[None]:

Constraints: Standard library only. emit may be slow or failing (a StatsD/OTel exporter). Must work for sync code; async support is a follow-up.

Examples:

  • with timed('db.query', emit, table='orders'): run_query() # took 250 ms → emit('db.query', 250.0, {'table': 'orders', 'outcome': 'ok'})
  • with timed('db.query', emit): raise TimeoutError() # after 500 ms → emit('db.query', 500.0, {'outcome': 'error', 'error_type': 'TimeoutError'}), then TimeoutError propagates

Hints:

  1. Which construct runs on both the normal and the exceptional exit of a yield inside @contextmanager?
  2. Why is time.time() the wrong clock for durations?
  3. What should happen if emit raises while an exception from the block is already propagating?
  4. @contextmanager objects are also ContextDecorators, and they create a fresh generator per decorated call.

Reference solution:

import logging
import time
from collections.abc import Callable, Iterator
from contextlib import contextmanager

logger = logging.getLogger(__name__)

Emit = Callable[[str, float, dict[str, str]], None]


@contextmanager
def timed(
    metric: str,
    emit: Emit,
    *,
    clock: Callable[[], float] = time.perf_counter,
    **tags: str,
) -> Iterator[None]:
    """Time the enclosed block and emit one latency sample, in milliseconds.

    Works as a context manager or a decorator. The block's exception always
    propagates; a failing `emit` is logged and ignored so telemetry can never
    change business behaviour.
    """
    outcome: dict[str, str] = {"outcome": "ok"}
    start = clock()
    try:
        yield
    except BaseException as exc:          # includes CancelledError, KeyboardInterrupt
        outcome = {"outcome": "error", "error_type": type(exc).__name__}
        raise
    finally:
        duration_ms = (clock() - start) * 1000.0
        try:
            emit(metric, duration_ms, {**tags, **outcome})
        except Exception:
            logger.warning("metric emit failed for %s", metric, exc_info=True)

Complexity: O(1) time and space per timed block, plus whatever emit costs. The overhead that matters in practice is emit: it runs inline, so a slow exporter adds directly to request latency (see follow-ups).

Tests:

import inspect
import time


class FakeClock:
    def __init__(self, *ticks: float) -> None:
        self.ticks = list(ticks)

    def __call__(self) -> float:
        return self.ticks.pop(0)


events = []


def emit(metric, ms, tags):
    events.append((metric, round(ms, 3), tags))


# success path, caller tags kept
with timed("db.query", emit, clock=FakeClock(10.0, 10.25), table="orders"):
    pass
assert events == [("db.query", 250.0, {"table": "orders", "outcome": "ok"})]

# error path: still emitted once, exception propagates unchanged
events.clear()
err = TimeoutError("db slow")
try:
    with timed("db.query", emit, clock=FakeClock(1.0, 1.5)):
        raise err
except TimeoutError as caught:
    assert caught is err
else:
    raise AssertionError("exception was swallowed")
assert events == [("db.query", 500.0, {"outcome": "error", "error_type": "TimeoutError"})]


# a failing emitter must not break the block or mask its exception
def broken_emit(*args):
    raise RuntimeError("statsd down")


with timed("x", broken_emit, clock=FakeClock(0.0, 1.0)):
    result = 42
assert result == 42
try:
    with timed("x", broken_emit, clock=FakeClock(0.0, 1.0)):
        raise KeyError("sku")
except KeyError:
    pass
else:
    raise AssertionError("KeyError lost")

# decorator use: each call timed separately
events.clear()
clock = FakeClock(0.0, 0.1, 5.0, 5.3)


@timed("pricing.quote", emit, clock=clock)
def quote() -> str:
    return "₹499"


assert quote() == "₹499" and quote() == "₹499"
assert [e[1] for e in events] == [100.0, 300.0]
assert all(e[2] == {"outcome": "ok"} for e in events)

# default clock is monotonic
assert inspect.signature(timed).parameters["clock"].default in (time.perf_counter, time.monotonic)

A common wrong solution (the tests catch it):

import time
from collections.abc import Callable, Iterator
from contextlib import contextmanager


@contextmanager
def timed(metric, emit, *, clock: Callable[[], float] = time.perf_counter, **tags) -> Iterator[None]:
    start = clock()
    yield                                   # no try/finally: errors skip the emit
    emit(metric, (clock() - start) * 1000.0, {**tags, "outcome": "ok"})

Alternatives and tradeoffs:

  • A class with __enter__/__exit__ (subclassing ContextDecorator): more explicit, and lets you expose elapsed to the caller after the block. Slightly more code; same semantics. Note that a single class instance used as a decorator is shared across calls, so per-call state must not live on the instance; @contextmanager avoids this by creating a fresh generator for every decorated call.
  • OpenTelemetry spans / prometheus_client.Histogram.time(): in a real service, use the standard library of your metrics stack rather than a home-grown emitter; this exercise is the core of what those do.
  • time.time() is wrong for durations: wall-clock time can jump backwards or forwards (NTP corrections), producing negative or huge latencies. perf_counter is monotonic and high-resolution.

Follow-ups:

  1. "Make it work on async def functions as a decorator." @contextmanager used as a decorator on a coroutine function only times the creation of the coroutine object, not its execution. Write an @asynccontextmanager twin, or a decorator that checks inspect.iscoroutinefunction(fn) and wraps with an async def that does with timed(...): return await fn(...). Count CancelledError as its own outcome (cancelled), since client disconnects shouldn't page anyone as errors.
  2. "The exporter is slow (5 ms per emit). How do you keep it off the request path?" Emit into an in-process aggregator (counters and histogram buckets updated under a lock, or per-thread) and flush in a background thread every few seconds; or use UDP StatsD, which is fire-and-forget. Never do a synchronous network call per sample on the hot path.
  3. "A teammate tags each sample with user_id and order_id. What do you say in review?" That's unbounded label cardinality: every distinct value creates a new time series and will blow up the metrics backend's memory and cost. Keep tags low-cardinality (endpoint, status class, region); put IDs in traces and logs, which are built for high cardinality.

Scoring: Strong: Uses try/except/finally so exactly one sample is emitted on both paths, re-raises the original exception, isolates emit failures, defaults to a monotonic clock, and tests with an injected clock. Raises the async-decorator pitfall and cardinality without prompting. Acceptable: Correct success and error paths with finally, but lets an emit failure escape or uses time.time(); fixes both when asked. Red flag: Emits only on success, swallows the block's exception, or tests by sleeping and asserting on real elapsed time.

SR-E-C02 · Idempotent ledger apply for at-least-once wallet events

Easy · Coding · Senior (5–8 years) · idempotency, at-least-once delivery, data integrity, atomicity, API design · 15 min

Problem:

A wallet service consumes credit/debit events from a queue with at-least-once delivery: the same event can arrive again in a later batch (redelivery after a consumer crash) or twice in the same batch. Implement Ledger.apply(events) so that:

  • Each event_id affects balances at most once, across all calls. Re-applying a batch that was already applied is a no-op.
  • A replayed event_id whose payload differs from the one already applied (or staged in this batch) is a contract violation: raise ConflictingEventError.
  • A batch is all-or-nothing: if it raises, no balance changes and no event is marked as applied, so the consumer can safely retry or dead-letter it.
  • apply returns how many events were newly applied.

Amounts are integer paise (₹1 = 100 paise); debits are negative.

Signature: def apply(self, events: Iterable[Event]) -> int:

Constraints: In-memory state is fine for the exercise; say how it maps to a database in production. Batches can be up to 10,000 events. Standard library only.

Examples:

  • ledger.apply([Event('e1', 'acct-asha', 50_000), Event('e2', 'acct-asha', -12_500)]) → 2; balances == {'acct-asha': 37_500}
  • ledger.apply([Event('e2', 'acct-asha', -12_500), Event('e3', 'acct-ravi', 1_000)]) # e2 redelivered → 1; balances == {'acct-asha': 37_500, 'acct-ravi': 1_000}
  • ledger.apply([Event('e3', 'acct-ravi', 9_999)]) → ConflictingEventError; balances unchanged

Hints:

  1. Where must the record of applied IDs live so a later batch can see it?
  2. What should a redelivered event be compared against to detect a conflict?
  3. To get all-or-nothing, validate the whole batch before mutating anything.

Reference solution:

from collections.abc import Iterable
from dataclasses import dataclass


@dataclass(frozen=True)
class Event:
    event_id: str
    account: str
    amount_paise: int


class ConflictingEventError(ValueError):
    """Same event_id seen with a different payload: a producer bug, not a retry."""


class Ledger:
    def __init__(self) -> None:
        self.balances: dict[str, int] = {}
        self._applied: dict[str, Event] = {}

    def apply(self, events: Iterable[Event]) -> int:
        """Apply a batch exactly once per event_id; all-or-nothing per batch."""
        staged: dict[str, Event] = {}
        for event in events:
            previous = self._applied.get(event.event_id) or staged.get(event.event_id)
            if previous is None:
                staged[event.event_id] = event
            elif previous != event:
                raise ConflictingEventError(
                    f"{event.event_id}: {previous} already recorded, got {event}"
                )
        # Validation passed: commit balances and the applied-ID record together.
        for event in staged.values():
            self.balances[event.account] = self.balances.get(event.account, 0) + event.amount_paise
        self._applied.update(staged)
        return len(staged)

Complexity: O(n) time per batch with dict lookups; O(n) extra space for the staged batch, plus O(total distinct events) for the applied-ID record, which grows forever unless bounded (see follow-ups).

Tests:

ledger = Ledger()
batch1 = [Event("e1", "acct-asha", 50_000), Event("e2", "acct-asha", -12_500)]
assert ledger.apply(batch1) == 2
assert ledger.balances == {"acct-asha": 37_500}

# full redelivery of a previous batch is a no-op
assert ledger.apply(batch1) == 0
assert ledger.balances == {"acct-asha": 37_500}

# partial redelivery across batches, plus a duplicate inside the batch
e3 = Event("e3", "acct-ravi", 1_000)
assert ledger.apply([Event("e2", "acct-asha", -12_500), e3, e3]) == 1
assert ledger.balances == {"acct-asha": 37_500, "acct-ravi": 1_000}

# a generator works (single pass)
assert ledger.apply(Event(f"g{i}", "acct-meera", 100) for i in range(3)) == 3
assert ledger.balances["acct-meera"] == 300

# conflicting replay: error, and the whole batch is rejected
before = dict(ledger.balances)
try:
    ledger.apply([Event("e4", "acct-ravi", 500), Event("e3", "acct-ravi", 9_999)])
except ConflictingEventError:
    pass
else:
    raise AssertionError("conflict not detected")
assert ledger.balances == before
# e4 was not marked applied, so a corrected retry applies it once
assert ledger.apply([Event("e4", "acct-ravi", 500)]) == 1
assert ledger.balances["acct-ravi"] == 1_500

# conflict inside a single batch
try:
    Ledger().apply([Event("x", "a", 1), Event("x", "a", 2)])
except ConflictingEventError:
    pass
else:
    raise AssertionError("in-batch conflict not detected")

assert Ledger().apply([]) == 0

A common wrong solution (the tests catch it):

from collections.abc import Iterable
from dataclasses import dataclass


@dataclass(frozen=True)
class Event:
    event_id: str
    account: str
    amount_paise: int


class ConflictingEventError(ValueError):
    pass


class Ledger:
    def __init__(self) -> None:
        self.balances: dict[str, int] = {}

    def apply(self, events: Iterable[Event]) -> int:
        seen: dict[str, Event] = {}           # dedupes within the batch only
        for event in events:
            if event.event_id in seen:
                if seen[event.event_id] != event:
                    raise ConflictingEventError(event.event_id)
                continue
            seen[event.event_id] = event
            self.balances[event.account] = self.balances.get(event.account, 0) + event.amount_paise
        return len(seen)

Alternatives and tradeoffs:

  • Database (the production answer): a processed_events(event_id PRIMARY KEY, payload_hash) table written in the same transaction as the balance updates. INSERT ... ON CONFLICT DO NOTHING (or catch the unique violation) gives dedup, the transaction gives all-or-nothing, and it works across consumer replicas. Storing a payload hash lets you detect conflicts without keeping full payloads.
  • Per-account sequence numbers instead of an ID set: apply only if seq == last_seq + 1. Constant state per account and detects gaps, but requires the producer to assign ordered sequences.
  • Transactional outbox / exactly-once in the broker (e.g. Kafka transactions) moves the problem but doesn't remove it: side effects outside the broker still need idempotency.

Follow-ups:

  1. "Two consumer replicas process overlapping batches concurrently. Does this design still hold?" Not in memory: each replica has its own _applied. With the DB version, the unique key on event_id makes the second insert fail or no-op inside its transaction, so only one replica applies the event. Lock ordering on account rows (or SELECT ... FOR UPDATE in a consistent order) avoids deadlocks when two batches touch the same accounts.
  2. "The applied-ID record grows by 50 million events a day. How do you bound it?" Keep IDs only for the redelivery window the broker can produce (e.g. 7 days, partitioned by date and dropped), and reject or alert on events older than the window. Or use per-account sequence numbers. Document the window as part of the data contract with producers.
  3. "Why raise on a conflicting replay instead of just ignoring it like other duplicates?" Same ID with different content is not a retry; it's a producer bug or an ID collision, and silently keeping the first version hides a money discrepancy. Failing loudly sends the batch to a dead-letter queue with an alert, so someone reconciles it.

Scoring: Strong: Persists applied IDs across calls, detects conflicting payloads, validates before mutating so a failed batch leaves no trace, handles in-batch duplicates and one-shot iterables. Maps it to a unique key in the same DB transaction without being asked. Acceptable: Cross-batch dedup works but the batch is partially applied on conflict; fixes the ordering once the all-or-nothing requirement is pointed at. Red flag: Dedupes only within a batch, uses floats for money, or proposes 'just make delivery exactly-once' instead of idempotent consumption.

SR-E-C03 · Bounded thread fan-out that keeps every result and every error

Easy · Coding · Senior (5–8 years) · concurrency, ThreadPoolExecutor, I/O-bound work, error isolation, API design · 15 min

Problem:

A KYC service must fetch documents for a list of customer IDs from a slow internal HTTP service (I/O-bound, a blocking client). Write fetch_all that calls fetch(key) for every key concurrently with at most max_workers calls in flight, and returns one Result per key in input order. A failure for one key must not lose the results of the others: record the exception in that key's Result instead.

max_workers < 1 raises ValueError. An empty input returns [].

Signature: def fetch_all(keys: Sequence[K], fetch: Callable[[K], V], *, max_workers: int = 8) -> list[Result[K, V]]:

Constraints: Up to a few thousand keys per call. Standard library only. fetch is blocking and thread-safe. Catch Exception, not BaseException.

Examples:

  • fetch_all(['c1', 'c2', 'c3'], fetch) # fetch('c2') raises TimeoutError → [Result('c1', doc1, None), Result('c2', None, TimeoutError(...)), Result('c3', doc3, None)]
  • fetch_all([], fetch) → []

Hints:

  1. Is this CPU-bound or I/O-bound, and what does that say about threads vs processes?
  2. What does executor.map do when one call raises?
  3. submit returns futures; you can keep them in a list aligned with the keys.

Reference solution:

from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
from typing import Generic, NamedTuple, TypeVar

K = TypeVar("K")
V = TypeVar("V")


class Result(NamedTuple, Generic[K, V]):
    key: K
    value: V | None
    error: Exception | None

    @property
    def ok(self) -> bool:
        return self.error is None


def fetch_all(
    keys: Sequence[K],
    fetch: Callable[[K], V],
    *,
    max_workers: int = 8,
) -> list[Result[K, V]]:
    """Call `fetch` for every key on a bounded thread pool.

    Results are returned in input order. A failing key produces a Result with
    `error` set; it never hides the other keys' results.
    """
    if max_workers < 1:
        raise ValueError(f"max_workers must be >= 1, got {max_workers}")
    if not keys:
        return []
    with ThreadPoolExecutor(max_workers=min(max_workers, len(keys)),
                            thread_name_prefix="fetch_all") as pool:
        futures = [pool.submit(fetch, key) for key in keys]
    results: list[Result[K, V]] = []
    for key, future in zip(keys, futures):
        exc = future.exception()
        if exc is None:
            results.append(Result(key, future.result(), None))
        elif isinstance(exc, Exception):
            results.append(Result(key, None, exc))
        else:                                   # KeyboardInterrupt and friends
            raise exc
    return results

Complexity: Wall time is about ceil(n / max_workers) times the per-call latency for uniform calls; CPU work is O(n). Memory is O(n) for the futures and results. Threads cost roughly a stack each (reserved, mostly not committed), which is why max_workers stays in the tens, not thousands.

Tests:

import threading
import time

def fake_fetch(key: str) -> str:
    if key.startswith("bad"):
        raise TimeoutError(f"upstream timeout for {key}")
    return f"doc:{key}"

out = fetch_all(["c1", "bad-2", "c3", "bad-4", "c5"], fake_fetch, max_workers=3)
assert [r.key for r in out] == ["c1", "bad-2", "c3", "bad-4", "c5"]
assert [r.value for r in out] == ["doc:c1", None, "doc:c3", None, "doc:c5"]
assert [type(r.error).__name__ if r.error else None for r in out] == [
    None, "TimeoutError", None, "TimeoutError", None]
assert [r.ok for r in out] == [True, False, True, False, True]

# order is input order even when completion order differs
def slow_first(key: int) -> int:
    time.sleep(0.05 if key == 0 else 0.0)
    return key * 10

assert [r.value for r in fetch_all([0, 1, 2], slow_first, max_workers=3)] == [0, 10, 20]

# concurrency is real but bounded
lock = threading.Lock()
active = peak = 0

def tracked(key: int) -> int:
    global active, peak
    with lock:
        active += 1
        peak = max(peak, active)
    time.sleep(0.03)
    with lock:
        active -= 1
    return key

assert [r.value for r in fetch_all(list(range(12)), tracked, max_workers=4)] == list(range(12))
assert 1 < peak <= 4, peak

assert fetch_all([], fake_fetch) == []
try:
    fetch_all(["c1"], fake_fetch, max_workers=0)
except ValueError:
    pass
else:
    raise AssertionError("max_workers=0 accepted")

A common wrong solution (the tests catch it):

from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
from typing import Generic, NamedTuple, TypeVar

K = TypeVar("K")
V = TypeVar("V")


class Result(NamedTuple, Generic[K, V]):
    key: K
    value: V | None
    error: Exception | None

    @property
    def ok(self) -> bool:
        return self.error is None


def fetch_all(keys, fetch, *, max_workers=8):
    if max_workers < 1:
        raise ValueError("max_workers must be >= 1")
    results = []
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        try:
            for key, value in zip(keys, pool.map(fetch, keys)):
                results.append(Result(key, value, None))
        except Exception as exc:            # map re-raises the first failure...
            results.append(Result(keys[len(results)], None, exc))   # ...and the rest are lost
    return results

Alternatives and tradeoffs:

  • executor.map: simplest, ordered, but re-raises the first exception when iteration reaches it and you lose everything after; only use it when any failure should fail the whole batch.
  • as_completed: process results as they finish (useful for streaming progress or early abort); reorder at the end if callers need input order.
  • asyncio + httpx.AsyncClient + Semaphore: better for thousands of concurrent calls with an async-capable client; a bigger change if the client is blocking.
  • Processes: wrong here; the work is waiting on I/O, which releases the GIL, so processes only add pickling and startup cost.

Follow-ups:

  1. "The KYC service starts returning 429s under your fan-out. What do you add?" Treat max_workers as a concurrency limit agreed with the owning team, add a client-side rate limiter (token bucket) and honour Retry-After, retry only idempotent reads with exponential backoff plus jitter, and stop early with a circuit breaker if the error rate crosses a threshold instead of hammering a struggling service.
  2. "One fetch hangs forever. What happens to fetch_all, and how do you fix it?" The with block waits for every future, so the whole call hangs. Set timeouts in the HTTP client itself (connect and read), since you can't kill a thread. Waiting with wait(futures, timeout=...) bounds the caller's wait, but the stuck thread stays alive and holds a slot.
  3. "Why return Result objects instead of raising an ExceptionGroup?" Partial success is the normal case for a batch: callers want the 4,990 documents that worked and a list of the 10 failures to retry. An ExceptionGroup is better when any failure means the batch failed and you want structured error handling with except*. Either way, the API should make the choice explicit rather than half-raising.

Scoring: Strong: Chooses threads with a reason, preserves input order, isolates failures per key, bounds concurrency, validates inputs, and avoids map's fail-fast behaviour deliberately. Tests prove ordering and the concurrency bound without flaky sleeps-as-assertions. Mentions client timeouts and rate limits. Acceptable: Working submit-based version with errors captured; needs prompting on ordering or on why map loses results. Red flag: Uses map and loses results after the first failure, spawns one thread per key without a bound, or picks processes for an I/O-bound call.

SR-E-D01 · Re-runnable daily load of UPI settlement files, with backfills

Easy · Design · Senior (5–8 years) · data pipelines, idempotency, partitioning, backfills, data contracts, observability · 35 min

Scenario:

A fintech in Bengaluru receives settlement files from three partner banks every night: CSVs dropped into an SFTP folder between 01:00 and 04:00 IST, together about 6 GB/day (~40 million UPI transactions). A Python job loads them into the warehouse table settlements, which finance uses at 09:00 IST to reconcile against the internal ledger.

Today the job is a cron script that appends whatever files it finds. Last month a bank re-sent a corrected file, the job was re-run by hand, and finance saw ₹3.2 crore of duplicated settlements. Separately, a schema change at one bank (a new column in the middle) silently shifted values into the wrong columns for two days.

Design the replacement job so it can be re-run any number of times for any date, supports backfills of the last 18 months, and fails loudly on bad input.

Clarifying questions:

  • Does each file cover exactly one settlement date per bank, and can a bank re-send a corrected file for a past date? (Yes, one bank-date per file; corrections arrive up to 7 days later and replace the whole file.)
  • Is there a stable transaction ID, and is it unique per bank or globally? (UTR is unique per bank; use (bank, utr).)
  • What does the warehouse support? Partition overwrite, MERGE, transactions? (Partitioned by settlement_date; supports atomic partition overwrite and MERGE.)
  • Who consumes the table, and what freshness do they need? (Finance at 09:00 IST; a dashboard refreshing hourly.)
  • Is there a schema agreement with each bank, and who do we call when it breaks?
  • What volume of rows is normal per bank per day, so we can alert on anomalies?
  • What's the retention for raw files? (Keep 2 years for audit.)

Reference design:

Unit of work = (bank, settlement_date). Every run, scheduled or backfill, processes a set of these units, and processing a unit is idempotent: the result depends only on the input file, not on how many times or in what order it ran.

1. Land raw, immutably. A sensor copies each new file from SFTP to object storage at raw/bank=<b>/date=<d>/<sha256>.csv and records it in a file_manifest table (bank, date, sha256, size, row count from the trailer, received_at). The checksum makes a re-sent identical file a no-op; a different checksum for the same bank-date marks it a correction and supersedes the older one in the manifest.

2. Validate against a data contract before anything touches settlements: expected header names (by name, not position, which would have caught the shifted column), types, amount as integer paise or Decimal (never float), non-null utr, row count equal to the trailer count, amount totals within tolerance of the bank's control total. Implemented as a Pydantic model or pandas schema checks in Python; failures send the file to quarantine/ and page the owner with the reason. Nothing partial is loaded.

3. Load by overwriting the unit, not appending. Write the validated rows to a staging table, then in one transaction replace the target rows for that (bank, settlement_date): either DELETE WHERE bank=? AND date=? + INSERT, or an atomic partition overwrite if the table is partitioned by bank and date. Re-running yesterday's unit replaces it with identical data; a corrected file replaces the old data. UNIQUE(bank, utr) as a backstop.

4. Track state. A load_runs table (unit, file sha, status, rows, started/finished, code version) gives an audit trail and lets the scheduler skip units already loaded from the same file sha.

5. Orchestrate. An orchestrator (Airflow/Dagster/Prefect, or even cron plus the state table) runs a daily DAG per bank; a backfill is the same task invoked over a date range with bounded parallelism (e.g. 8 dates at a time), so the warehouse and the SFTP server aren't overwhelmed. No separate "backfill code path".

6. Observe. Per unit: rows loaded vs trailer count, amount total vs control total, lateness of the file vs its SLA (04:00 IST), duration. Alert on missing files by 04:30, on row counts outside ±30% of the trailing 4-week median, and on any quarantined file. A small "data ready" marker per date tells finance the 09:00 report is safe to run.

Python-specific: stream the CSV in chunks (pandas.read_csv(chunksize=...) with explicit dtype and usecols, or csv + batched inserts), keep memory flat regardless of file size, and make the job a package with a CLI (load --bank X --date 2025-03-31) that the orchestrator calls.

Trade-offs:

  • Overwrite per partition vs row-level MERGE. Overwrite is simpler and naturally handles deletions in corrected files; MERGE touches fewer rows but needs a rule for rows missing from the corrected file. At 40M rows/day split across banks, overwriting a bank-day is cheap, so prefer it.
  • Fail the whole file vs load good rows and quarantine bad rows. Finance reconciliation needs completeness, so fail the file; a product analytics feed might prefer partial loads plus a reject table.
  • Orchestrator vs cron. An orchestrator gives retries, backfill UI and lineage, at the cost of running it. With one job, cron plus the state table is defensible; with ten pipelines, standardise on one orchestrator.
  • Strict contract vs tolerant parsing. Strict (fail on unknown or missing columns) catches silent shifts but pages someone when a bank adds a harmless column; read by header name and fail only on missing or renamed required columns.

Deep-dive probes:

  • "A bank re-sends 2025-03-12 with 200 rows removed (they were reversals)." Look for: the unit is overwritten, so removed rows disappear; an append or upsert-only design would keep them. The manifest marks the old file superseded, and the change is visible in an audit report.
  • "Backfill 18 months after a bug fix in fee calculation." Look for: the same idempotent task over a date range, bounded parallelism, running against raw files (not the already-transformed table), code version recorded per run, a dry run comparing totals before overwriting, and telling finance which periods will change.
  • "The 09:00 report ran while the 2025-04-01 load was halfway through." Look for: atomic swap per unit (staging + one transaction, or partition overwrite) so readers never see half a day; a readiness marker or SLA check that the report waits on.
  • "A file is 3x the usual size." Look for: chunked streaming so memory doesn't scale with file size, an anomaly alert on row count, and a policy decision: load and alert, or quarantine until confirmed.

Rubric: Strong: Defines the unit of idempotency (bank, date) and makes reruns overwrite it atomically; lands raw files immutably with checksums; validates a data contract by column name with row-count and control-total checks before loading; backfills reuse the same task with bounded parallelism; concrete alerts tied to the 09:00 SLA. Handles corrected files that remove rows. Acceptable: Gets idempotent reruns via delete-then-insert or MERGE on UTR and adds schema checks, but needs prompting on atomicity for readers, corrected files with deletions, or observability. Red flag: Keeps appending and dedupes downstream 'with DISTINCT', relies on 'just don't rerun it', parses amounts as floats, or treats backfill as a separate one-off script.

SR-E-D02 · Caching layer for a slow product-pricing API

Easy · Design · Senior (5–8 years) · caching, Redis, cache-aside, TTL, invalidation, cache stampede, FastAPI · 35 min

Scenario:

An e-commerce company's pricing service (FastAPI, 12 pods) answers GET /price?sku=...&pincode=... for the product page and the cart. Each request calls a rules engine and two internal services; p50 is 180 ms and p99 is 1.4 s. Traffic is ~4,000 requests/s normally and ~25,000 requests/s during the Big Billion-style sale, concentrated on a few thousand hot SKUs. The rules engine falls over above ~6,000 requests/s.

Prices change when merchandisers edit them (a few thousand edits a day, bursts at sale start) and when scheduled offers start or end on the hour. Product wants p99 under 150 ms during the sale, and the cart must never charge a stale price.

Design the caching.

Clarifying questions:

  • How stale may a product-page price be? (Up to 60 seconds is fine on the product page.) And in the cart and checkout? (Must be current; checkout re-validates.)
  • Does the price depend on the user (loyalty tier, coupons) or only on SKU and pincode? (SKU + pincode + a small set of customer segments; coupons are applied later in the cart.)
  • How many distinct (SKU, pincode-zone) combinations are there? (~2M SKUs, ~40 pricing zones, but fewer than 50k combinations are hot.)
  • Do merchandiser edits publish an event? (Yes, a price.changed event on Kafka with SKU and zone.)
  • Is there already a Redis cluster, and what's the memory budget?
  • {'What should happen if the rules engine is down': 'serve the last known price or an error?'}

Reference design:

Key design: cache the computed price per (sku, zone, segment), not per pincode (map pincode to zone first, which collapses ~19,000 pincodes into ~40 zones and multiplies the hit rate). Key: price:v3:{sku}:{zone}:{segment}, where v3 is a schema version so a deploy that changes the value format doesn't read old entries.

Two tiers, cache-aside: 1. In-process LRU per pod (e.g. 20k entries, TTL 5 s): absorbs the hottest SKUs during the sale with no network hop. Short TTL bounds staleness across pods. 2. Redis (TTL 60 s plus random jitter of ±10%, so entries created together don't expire together): shared across pods. Values are small JSON or msgpack with price in paise, currency, computed_at, and the price version from the source.

Read path: local → Redis → compute, then write back to both. Use an async Redis client so a Redis call doesn't block the event loop, with a tight timeout (e.g. 20 ms); on Redis error, treat it as a miss and compute: the cache must never be a hard dependency.

Invalidation: a consumer of price.changed deletes (or overwrites with the new price) the Redis keys for that SKU and zone. Scheduled offers: cache entries carry valid_until = the next scheduled change, and TTL is min(60 s, valid_until - now), so an offer ending at 12:00 isn't served at 12:00:40. In-process entries rely on their 5 s TTL (or a pub/sub broadcast if 5 s is too stale).

Stampede protection: when a hot key expires at 25k requests/s, hundreds of requests would recompute it at once and knock over the rules engine. Use request coalescing in-process (one asyncio future per key; concurrent callers await the same future), plus a short Redis lock (SET key:lock NX PX 2000) so only one pod recomputes, and/or stale-while-revalidate: keep serving the old value for a few seconds while one background refresh runs. Pre-warm the top SKUs before the sale starts.

Correctness boundary: the cart and checkout read through a fresh=True path that bypasses the caches (or checks the source version), so the product page may show a price that is up to a minute old but the charged price is always current. If the two differ, the cart shows the updated price before payment.

Observability: hit ratio per tier, recompute rate, coalesced-wait count, Redis latency and errors, staleness (now - computed_at) histogram, and a canary that compares cached vs freshly computed prices for a sample of SKUs.

Trade-offs:

  • TTL-only vs event-driven invalidation. TTL-only is simple and self-healing but bounds staleness only by the TTL; events give near-instant updates but can be lost or delayed, so keep the TTL as a backstop.
  • Delete-on-change vs write-through on change. Deleting is simpler and avoids racing writers; writing the new value avoids a miss storm on hot SKUs when merchandisers edit thousands of prices at sale start.
  • In-process tier. Big latency and load win; costs memory per pod and creates per-pod inconsistency for up to its TTL. Keep it small and short-lived.
  • Serve stale on backend failure. Better availability for the product page; unacceptable for checkout. Make the policy explicit per caller.

Deep-dive probes:

  • "At 12:00 the sale starts, 3,000 SKUs get new prices, and the rules engine falls over." Look for: stampede analysis (mass invalidation at once), write-through of new prices from the event instead of delete, pre-warming, request coalescing, a concurrency limit on recomputation, and serving stale for the product page while it recovers.
  • "A customer saw ₹1,299 on the product page and was charged ₹1,499." Look for: the explicit freshness contract per caller, checkout bypassing or validating the cache with a version, and showing the price change in the cart before payment rather than trying to make the product page perfectly fresh.
  • "Race: an old recompute writes after the invalidation." Request A misses, computes with the old price; the edit lands and deletes the key; A then writes the old price back for 60 s. Look for: versioned values (write only if the version is newer, via a Lua script or WATCH), or short TTLs on writes from recomputes that started before the last invalidation.
  • "Redis has a 200 ms latency spike." Look for: client timeouts, falling back to compute (with the concurrency limit protecting the rules engine), a circuit breaker on Redis, and the in-process tier carrying the hot set.

Rubric: Strong: Normalises the key (pincode to zone, versioned prefix), layers in-process and Redis tiers with jittered TTLs, invalidates on events with TTL as a backstop, handles scheduled price changes, protects against stampedes with coalescing or locks, and draws a hard freshness line at checkout. Treats Redis as optional and names concrete cache metrics. Acceptable: Redis cache-aside with a TTL and event-based deletion; recognises the stampede and checkout-staleness problems when prompted and proposes reasonable fixes. Red flag: Caches everything with a long TTL and no invalidation, makes Redis a hard dependency, uses functools.lru_cache in each pod as the whole design, or doesn't distinguish product-page staleness from what the customer is charged.

Easy & Tricky (ET)

SR-ET-O01 · wait_for timeout around to_thread, then a retry

Easy & Tricky · Output · Senior (5–8 years) · asyncio, cancellation, asyncio.to_thread, timeouts, idempotency · 5 min

Question: A checkout endpoint calls a blocking payment SDK through asyncio.to_thread and protects it with a timeout. On timeout, the client retries. What does this print?

import asyncio
import time

charges: list[str] = []


def charge_card(order_id: str, amount: int) -> None:
    time.sleep(0.3)                      # slow payment gateway
    charges.append(order_id)
    print(f"gateway charged {order_id} ₹{amount}")


async def checkout(order_id: str, timeout: float) -> str:
    try:
        await asyncio.wait_for(asyncio.to_thread(charge_card, order_id, 499), timeout)
        return "paid"
    except TimeoutError:
        return "timeout"


async def main() -> None:
    print("attempt 1:", await checkout("ORD-7", timeout=0.1))
    print("charges so far:", len(charges))
    print("attempt 2:", await checkout("ORD-7", timeout=1.0))
    print("charges:", charges)


asyncio.run(main())

Expected output:

attempt 1: timeout
charges so far: 0
gateway charged ORD-7 ₹499
gateway charged ORD-7 ₹499
attempt 2: paid
charges: ['ORD-7', 'ORD-7']

The trap: "attempt 1: timeout, charges so far: 0, then one gateway charged line, attempt 2: paid, charges: ['ORD-7']." The candidate assumes that when wait_for times out it cancels the work, so the first charge never happens. That holds for pure coroutines, and the charges so far: 0 line seems to confirm it.

Explanation:

wait_for cancels the awaitable it was given, here the asyncio future that to_thread uses to wait for the pool thread. A running thread cannot be cancelled. Cancelling the future just stops anyone waiting for it; charge_card keeps sleeping in its thread and completes the charge.

Timeline: at 0.1 s attempt 1 times out (the thread has 0.2 s to go), so charges is still empty when printed. Attempt 2 starts a second thread at about 0.1 s. At 0.3 s the first thread finishes and prints; at about 0.4 s the second one finishes, and attempt 2 returns paid. The customer has been charged twice.

The same is true of any blocking work offloaded with run_in_executor, of process pools, and of a remote server that already received the request: a client-side timeout means "I stopped waiting", not "it didn't happen".

Real-world bug: a duplicated payment, booking or ledger entry after a timeout plus retry. The fix is not a longer timeout: send an idempotency key (here derived from ORD-7) that the gateway or your own table deduplicates on, and on timeout query the status before retrying.

Takeaway: cancellation is cooperative and only reaches code that awaits; threads, processes and remote side effects run to completion regardless.

Follow-ups:

  1. "Would async with asyncio.timeout(0.1): (3.11+) instead of wait_for change the output?" No. It cancels the current task at the deadline, which again only stops the await; the thread still finishes and charges. Both are the right tools for bounding how long you wait, neither stops thread work.
  2. "How would you make the slow work actually stoppable?" Use a client with its own timeouts (connect/read timeouts on the HTTP session) so the blocking call returns by itself; or move to a natively async client where cancellation reaches the socket; or pass a threading.Event that the work checks between steps. Even then, a request already sent may be processed server-side, so idempotency is still required.
  3. "The default executor has a fixed number of threads. What happens under load if the gateway hangs?" Timed-out calls keep occupying threads. Retries add more, the pool (min(32, cpu+4) threads by default) saturates, and every other to_thread call in the process queues behind them: the outage spreads. Use a dedicated bounded executor for the gateway, a circuit breaker, and a cap on retries.

Scoring: Strong: Exact output including two charges and their position before attempt 2: paid. States that threads can't be cancelled, frames it as a duplicate-charge incident, and proposes idempotency keys plus status checks, not just tuning timeouts. Acceptable: Initially predicts one charge, but when asked what cancellation does to a running thread, reasons to the double charge and suggests an idempotency key. Red flag: Insists wait_for kills the thread, or suggests the fix is a larger timeout or removing retries without addressing duplicate side effects.

SR-ET-O02 · lru_cache returns the same list, and a preview mutates it

Easy & Tricky · Output · Senior (5–8 years) · functools.lru_cache, caching, aliasing, cache poisoning · 4 min

Question: A fee service caches per-state fee slabs loaded from the config DB with lru_cache. A new "festive offer preview" endpoint shows what fees would look like if they were waived. What does this print?

from functools import lru_cache


@lru_cache(maxsize=None)
def load_fee_slabs(state: str) -> list[dict]:
    print(f"loading slabs for {state}")
    return [{"upto": 1_000, "fee": 0}, {"upto": 10_000, "fee": 5}]


def fee(state: str, amount: int) -> int:
    for slab in load_fee_slabs(state):
        if amount <= slab["upto"]:
            return slab["fee"]
    return 20


def preview_festive_offer(state: str) -> list[dict]:
    slabs = load_fee_slabs(state)
    for slab in slabs:
        slab["fee"] = 0                  # "just for the preview"
    return slabs


print(fee("KA", 5_000))
preview_festive_offer("KA")
print(fee("KA", 5_000))
print(fee("MH", 5_000))
print(load_fee_slabs.cache_info())

Expected output:

loading slabs for KA
5
0
loading slabs for MH
5
CacheInfo(hits=2, misses=2, maxsize=None, currsize=2)

The trap: "5, 5, 5": the preview only changed its "own" local slabs, so real fees are unaffected. Candidates think of the cache as returning a fresh result, the way calling the uncached function would.

Explanation:

lru_cache stores the object the function returned and hands back that same object on every hit; it does not copy. preview_festive_offer gets the cached list and mutates its dicts in place, so the cache now holds waived fees. Every later fee("KA", ...) call in this process returns 0, a real revenue bug that survives until the process restarts or cache_clear() is called. MH is a separate cache entry, loaded fresh, so it's unaffected.

cache_info(): misses for the first KA call and for MH; hits for the preview's call and the second KA fee lookup, giving 2 and 2.

Why it's nasty in production: the corruption is per-process, so with several workers only some pods return wrong fees, and the effect depends on which worker served a preview first. Restarting "fixes" it, which hides the cause.

Takeaway: only cache values that are immutable (tuples, frozen dataclasses, MappingProxyType), or have the cached function's callers treat results as read-only and copy before modifying. A cache is shared mutable state.

Follow-ups:

  1. "How would you make this cache safe by construction rather than by convention?" Return immutable data: a tuple of frozen dataclasses (@dataclass(frozen=True) class Slab: upto: int; fee: int). The preview then has to build new objects (dataclasses.replace(s, fee=0)), and an accidental in-place write raises. Alternatively, cache the raw data privately and return copy.deepcopy from a thin wrapper, at a cost on every call.
  2. "Fee slabs change in the config DB a few times a day. What's wrong with lru_cache(maxsize=None) for them?" It never expires, so each process serves stale fees until it restarts, and different pods disagree. Use a TTL cache (or a version number checked cheaply on each call), bounded size, and an explicit invalidation path (pub/sub or a config version bump). Also decide what happens when the reload fails: serve the last known good value and alert.
  3. "Someone puts @lru_cache on an instance method of a long-lived service class. What do you flag in review?" The cache is on the function, shared across instances, and keys include self, so it holds strong references to every instance (a memory leak) and requires self to be hashable. Prefer a per-instance cache (functools.cached_property for no-arg values, or a dict on the instance) or a module-level cache keyed on real inputs.

Scoring: Strong: Exact output including cache_info counts. Explains the cache returns the same object, describes the per-process production impact, and proposes immutable return types. Acceptable: Gets 5, 0, 5 once reminded that the cache stores the returned object; fixes it with a copy at the call site. Red flag: Predicts 5, 5, 5, or thinks lru_cache deep-copies results; or proposes clearing the cache after the preview as the fix.

SR-ET-O03 · model_copy(update=...) slips past a validated payout contract

Easy & Tricky · Output · Senior (5–8 years) · pydantic v2, data contracts, validation, model_copy · 5 min

Question: A payouts service treats its Pydantic model as the data contract: amounts must be positive, and validate_assignment=True is on so "nothing invalid can get in after construction". A reversal flow derives a new payout from an existing one. What does this print?

from decimal import Decimal

from pydantic import BaseModel, ConfigDict, Field, ValidationError


class Payout(BaseModel):
    model_config = ConfigDict(validate_assignment=True)

    payout_id: str
    amount: Decimal = Field(gt=0)
    currency: str = "INR"


p = Payout(payout_id="P-1", amount="1500.50")
print(repr(p.amount))

try:
    p.amount = "-20"
except ValidationError as exc:
    print("assignment rejected:", exc.errors()[0]["type"])

q = p.model_copy(update={"amount": "-20", "currency": 42})
print(repr(q.amount), repr(q.currency))

try:
    Payout.model_validate(q.model_dump(warnings=False))
except ValidationError as exc:
    print("revalidation:", sorted(e["type"] for e in exc.errors()))

Expected output:

Decimal('1500.50')
assignment rejected: greater_than
'-20' 42
revalidation: ['greater_than', 'string_type']

The trap: "ValidationError on the model_copy line" (since construction and assignment are both validated), or "Decimal('-20') and '42'" (assuming the update is at least coerced). Both assume every way of producing a model instance runs the validators.

Explanation:

Construction validates and coerces ("1500.50" becomes Decimal('1500.50')), and with validate_assignment=True the assignment p.amount = "-20" is validated too and rejected by the gt=0 constraint (error type greater_than).

model_copy(update=...) does not validate. It copies the instance's __dict__ and writes the update values in as they are, for speed. So q holds the string '-20' in a Decimal field and the int 42 in a str field: a model instance that violates its own contract and type hints. Nothing fails until something downstream does arithmetic on q.amount, writes it to the DB, or serialises it (Pydantic would emit a serialisation warning, silenced here with warnings=False).

Running the dumped data back through model_validate exposes both violations: greater_than for the amount and string_type for the currency (Pydantic's default lax mode does not coerce an int to str).

Takeaway: model_copy(update=...) and model_construct() are trusted fast paths. At a contract boundary, build derived objects through validation: Payout.model_validate({**p.model_dump(), **changes}).

Follow-ups:

  1. "Where else can invalid data end up inside a Pydantic model?" model_construct() skips validation entirely, and mutating a mutable field in place (order.items.append(bad)) isn't an assignment, so validate_assignment never sees it. Frozen models plus immutable field types (tuples instead of lists) close most of these paths.
  2. "How would you enforce that every derived payout is validated, as a team standard?" Provide a method on the model, e.g. def with_changes(self, **changes) -> Self: return type(self).model_validate({**self.model_dump(), **changes}), make the models frozen=True, and flag model_copy(update= and model_construct in review (or a lint rule) unless there's a comment justifying the trusted path.
  3. "This model is also the contract for events published to other teams. What else matters beyond validation?" Versioning and compatibility: publish the JSON Schema (model_json_schema()), make additive changes optional with defaults, never repurpose a field, decide extra='forbid' for inputs vs 'ignore' for consumers, and run contract tests in CI that check old fixtures still validate against the new model.

Scoring: Strong: Exact output. States plainly that model_copy(update=) skips validation and type coercion, names model_construct as the same kind of path, and proposes a validated derive-with-changes helper as a team standard. Acceptable: Expects an error on the copy, but once told it doesn't validate, predicts the raw values and the revalidation errors correctly. Red flag: Assumes type hints are enforced at runtime everywhere, or says validate_assignment covers copies.

SR-ET-O04 · Path traversal through join and a prefix check

Easy & Tricky · Output · Senior (5–8 years) · security, path traversal, os.path, pathlib, input validation · 4 min

Question: An invoice-download endpoint takes a file name from the URL and serves it from /srv/invoices. A reviewer adds a "safety check". What does this print? (POSIX path functions are used so the result is the same on every OS.)

import posixpath
from pathlib import PurePosixPath

BASE = "/srv/invoices"

for name in ["INV-1.pdf", "../.env", "/etc/passwd"]:
    print(posixpath.join(BASE, name))

print(PurePosixPath(BASE) / "/etc/passwd")


def looks_safe(name: str) -> bool:
    full = posixpath.normpath(posixpath.join(BASE, name))
    return full.startswith(BASE)


print(looks_safe("../.env"), looks_safe("../invoices-archive/2023.zip"))

target = PurePosixPath(posixpath.normpath(posixpath.join(BASE, "../invoices-archive/2023.zip")))
print(target, target.is_relative_to(BASE))

Expected output:

/srv/invoices/INV-1.pdf
/srv/invoices/../.env
/etc/passwd
/etc/passwd
False True
/srv/invoices-archive/2023.zip False

The trap: "/srv/invoices//etc/passwd" for the absolute name, and "False False" for the prefix check, i.e. believing join always keeps the base directory and that startswith(BASE) after normpath confines the path.

Explanation:

Three behaviours:

  1. join doesn't resolve ... /srv/invoices/../.env is a valid path that the OS resolves to /srv/.env.
  2. An absolute component discards everything before it. join(BASE, "/etc/passwd") is /etc/passwd, and pathlib's / operator does the same. A user-supplied absolute path walks straight out of the base directory.
  3. A string prefix is not a directory boundary. After normpath, ../.env becomes /srv/.env and is correctly rejected, but ../invoices-archive/2023.zip becomes /srv/invoices-archive/2023.zip, which starts with the string /srv/invoices. The check passes and serves a file from a sibling directory. is_relative_to compares path components, so it correctly says False.

Even the component-wise check is not enough on a real filesystem: normpath is purely lexical and a symlink inside the base can point anywhere. Production code resolves first, then compares: full = (base / name).resolve(); full.is_relative_to(base.resolve()). Better still, don't use client input as a path at all: look the invoice up by ID in the DB, check that the caller owns it, and use the stored server-side path.

Takeaway: confine paths with resolve() plus is_relative_to() (or os.path.commonpath), never with startswith on strings.

Follow-ups:

  1. "Where else does this bug class show up in Python services?" Extracting archives: tarfile and zipfile members can contain ../ or absolute names (Zip Slip). On 3.12+ use tar.extractall(path, filter='data') (the default from 3.14), and validate zip member names yourself. Also templating paths from user input, static-file handlers, and upload handlers that keep the client's filename.
  2. "Is resolve() + is_relative_to() free of races?" No. Between the check and open(), a symlink could be swapped (TOCTOU) if an attacker can write inside the base directory. Mitigations: base directories that untrusted users can't write to, opening with O_NOFOLLOW or openat-style APIs, or serving from object storage keyed by ID so there is no filesystem path at all.
  3. "How would you test this endpoint for traversal?" A parametrised test with ../, absolute paths, URL-encoded %2e%2e%2f (after the framework's decoding), backslashes, a symlink in a temp base dir pointing outside, and a sibling directory sharing the prefix. Assert 404/400 and that nothing outside the base is read.

Scoring: Strong: Exact output. Explains absolute-component behaviour, that normpath is lexical, and why the string prefix check fails on a sibling directory. Proposes resolve() + is_relative_to, and ideally lookup by ID with an ownership check. Acceptable: Gets the join lines right but trusts the startswith check; recognises the sibling-directory hole when shown the invoices-archive case. Red flag: Proposes stripping ../ with str.replace as the fix, or doesn't see why serving user-named files is a security concern.

SR-ET-C01 · Stable shard assignment for user IDs across worker processes

Easy & Tricky · Coding · Senior (5–8 years) · hashing, hash randomisation, partitioning, multiprocessing, determinism · 12 min

Problem:

An event pipeline runs num_shards worker processes (often on different machines). Every event for a given user must go to the same shard so per-user state stays in one place. Producers call shard_for(user_id, num_shards) to pick the shard.

Write shard_for. It must: - return an int in range(num_shards); - return the same shard for the same key in every process, on every machine, after every restart; - spread keys roughly evenly; - raise ValueError if num_shards < 1.

Signature: def shard_for(key: str, num_shards: int) -> int:

Constraints: Keys are arbitrary Unicode strings (user IDs like "u-91827" or emails). Standard library only. Called ~50k times per second per producer, so it should be cheap, but correctness first.

Examples:

  • shard_for('u-91827', 8) → an int in 0..7, identical in every process and on every run
  • shard_for('u-91827', 0) → ValueError

Hints:

  1. Run python -c "print(hash('u-91827'))" twice. What do you see?
  2. Which hash functions in the standard library are defined by a specification rather than by the interpreter?
  3. How do you turn a digest into an integer?

Reference solution:

import hashlib


def shard_for(key: str, num_shards: int) -> int:
    """Map `key` to a shard in range(num_shards), stable across processes and runs.

    Uses a spec-defined hash (BLAKE2b, 8-byte digest) of the UTF-8 bytes, not
    the built-in `hash()`, whose value for str/bytes is randomised per process.
    """
    if num_shards < 1:
        raise ValueError(f"num_shards must be >= 1, got {num_shards}")
    digest = hashlib.blake2b(key.encode("utf-8"), digest_size=8).digest()
    return int.from_bytes(digest, "big") % num_shards

Complexity: O(len(key)) time to hash, O(1) extra space. BLAKE2b with an 8-byte digest costs well under a microsecond for short keys; int.from_bytes and % are negligible. Modulo bias from a 64-bit value reduced by a small num_shards is around 2^-60, i.e. irrelevant.

Tests:

# ---- tests ----
import os
import subprocess
import sys
from collections import Counter

keys = [f"u-{i}" for i in range(20_000)] + ["asha@example.in", "रवि", ""]

# range
assert all(0 <= shard_for(k, 8) < 8 for k in keys)
assert shard_for("anything", 1) == 0

# roughly even: every shard within 10% of the mean
counts = Counter(shard_for(k, 8) for k in keys)
mean = len(keys) / 8
assert len(counts) == 8 and all(abs(c - mean) < 0.1 * mean for c in counts.values()), counts

# stable across processes with different hash seeds (simulates other workers/restarts)
source = open(__file__, encoding="utf-8").read().split("# ---- tests ----")[0]
probe = keys[:200]
child_code = source + f"\nprint([shard_for(k, 8) for k in {probe!r}])\n"
expected = str([shard_for(k, 8) for k in probe])
for seed in ("1", "12345", "random"):
    env = {**os.environ, "PYTHONHASHSEED": seed}
    out = subprocess.run([sys.executable, "-c", child_code], env=env,
                         capture_output=True, text=True, check=True).stdout.strip()
    assert out == expected, f"shard assignment changed with PYTHONHASHSEED={seed}"

for bad in (0, -3):
    try:
        shard_for("u-1", bad)
    except ValueError:
        pass
    else:
        raise AssertionError(f"num_shards={bad} accepted")

A common wrong solution (the tests catch it):

def shard_for(key: str, num_shards: int) -> int:
    if num_shards < 1:
        raise ValueError(f"num_shards must be >= 1, got {num_shards}")
    return hash(key) % num_shards

Alternatives and tradeoffs:

  • hash(key) % n: the obvious answer and wrong. Since Python 3.3, str and bytes hashes are salted per process (PYTHONHASHSEED, a defence against hash-flooding DoS), so every worker and every restart computes a different shard. Setting PYTHONHASHSEED=0 everywhere "works" but disables that protection globally and still ties you to CPython's internal algorithm, which other languages can't reproduce.
  • hashlib.md5 / sha256: also correct and portable to Go/Java producers; slightly slower than BLAKE2b, irrelevant at this rate. MD5 is fine here because this isn't a security use (pass usedforsecurity=False on FIPS builds).
  • zlib.crc32: fast and stable, but a 32-bit checksum with weaker distribution; acceptable for coarse sharding.
  • Consistent hashing / jump consistent hash / rendezvous hashing: needed when num_shards changes (see follow-ups). Plain modulo moves most keys when n changes.

Follow-ups:

  1. "Ops wants to go from 8 to 9 shards. What happens with modulo, and what would you use instead?" About 8/9 of keys change shard, so per-user state has to move almost entirely. Jump consistent hash (a few lines, stateless, moves only ~1/9 of keys) or rendezvous hashing (pick the shard with the highest hash(key, shard), also handles removing arbitrary shards) keep movement minimal. A ring with virtual nodes suits weighted or heterogeneous shards. Whatever you pick, the migration still needs a handover plan for in-flight state.
  2. "A Java service must route to the same shards. What do you put in the data contract?" The exact algorithm: UTF-8 encoding of the key, BLAKE2b with 8-byte digest, big-endian unsigned integer, modulo n, plus a few golden test vectors both codebases assert in CI. Without the encoding and byte order spelled out, implementations drift.
  3. "Where else does per-process hash() randomisation bite in production?" Iteration order of sets of strings differs between processes, so anything that serialises a set (cache keys, file names, 'stable' IDs, test snapshots) changes between runs; and caches keyed on hash(obj) stored outside the process are wrong after restart. Sort before serialising and use hashlib for anything persisted or shared.

Scoring: Strong: Rejects hash() immediately and explains salting per process and why, uses a spec-defined digest with explicit encoding, validates num_shards, and raises resharding (consistent or jump hashing) and cross-language test vectors. Acceptable: Starts with hash(), but recognises the randomisation when asked how it behaves across processes, and switches to hashlib. Red flag: Ships hash(key) % n, or 'fixes' it by setting PYTHONHASHSEED=0 in the Dockerfile without understanding what that disables.

SR-ET-C02 · Crash-safe checkpoint file for a resumable backfill

Easy & Tricky · Coding · Senior (5–8 years) · file I/O, atomic writes, os.replace, fsync, backfills, durability · 15 min

Problem:

A backfill job reprocesses 18 months of GST invoices and writes a JSON checkpoint (last processed date, counts, cursor) after each day, so it can resume after a crash or a pod eviction. Last week a pod was OOM-killed mid-write and the job couldn't restart: the checkpoint file was half-written JSON.

Write write_checkpoint(path, state) so that any reader of path, at any moment, sees either the complete previous checkpoint or the complete new one, never a partial file. If serialisation fails, the previous checkpoint must be untouched and no stray temporary files may be left behind.

Signature: def write_checkpoint(path: str | os.PathLike[str], state: dict[str, Any]) -> None:

Constraints: POSIX filesystem (local disk or a PVC). Standard library only. The state is small (KBs). Non-ASCII text (e.g. customer names, ₹) must round-trip.

Examples:

  • write_checkpoint('ckpt.json', {'last_date': '2025-03-31', 'rows': 1204331}) → ckpt.json contains exactly that JSON
  • write_checkpoint('ckpt.json', {'last_date': '2025-04-01', 'cursor': object()}) → TypeError; ckpt.json still holds the 2025-03-31 checkpoint; no temp files remain

Hints:

  1. What does open(path, 'w') do to the existing file before a single byte of new data is written?
  2. Which filesystem operation replaces a file atomically, and what does it require of the source and destination?
  3. Where must the temporary file live for that operation to be atomic?
  4. After the rename, what still has to happen for the data to survive a power loss?

Reference solution:

import contextlib
import json
import os
import tempfile
from typing import Any


def write_checkpoint(path: str | os.PathLike[str], state: dict[str, Any]) -> None:
    """Atomically replace the JSON checkpoint at `path` with `state`.

    Serialise first (so a bad value fails before touching disk), write to a temp
    file in the same directory, fsync it, then os.replace() it over the target.
    Readers see the old file or the new file, never a partial one.
    """
    path = os.fspath(path)
    payload = json.dumps(state, ensure_ascii=False, sort_keys=True, indent=1)
    directory = os.path.dirname(os.path.abspath(path))

    fd, tmp_path = tempfile.mkstemp(prefix=".ckpt-", suffix=".tmp", dir=directory)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as tmp:
            tmp.write(payload)
            tmp.flush()
            os.fsync(tmp.fileno())          # data on disk before the rename
        os.replace(tmp_path, path)          # atomic on POSIX within one filesystem
    except BaseException:
        with contextlib.suppress(FileNotFoundError):
            os.unlink(tmp_path)
        raise

    # Make the rename itself durable (directory entry), where supported.
    if hasattr(os, "O_DIRECTORY"):
        dir_fd = os.open(directory, os.O_DIRECTORY)
        try:
            os.fsync(dir_fd)
        finally:
            os.close(dir_fd)

Complexity: O(size of state) time and space. Each checkpoint costs two fsync calls (file and directory), typically a few milliseconds on SSD and more on network volumes; at one checkpoint per processed day that's negligible. If checkpoints were per record, batch them.

Tests:

import json
import os
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as d:
    ckpt = Path(d) / "gst_backfill.json"

    # first write, then overwrite
    write_checkpoint(ckpt, {"last_date": "2025-03-31", "rows": 1_204_331})
    assert json.loads(ckpt.read_text(encoding="utf-8")) == {"last_date": "2025-03-31", "rows": 1_204_331}
    good = {"last_date": "2025-04-01", "rows": 1_250_002, "note": "₹ totals reconciled for रवि"}
    write_checkpoint(str(ckpt), good)
    assert json.loads(ckpt.read_text(encoding="utf-8")) == good

    # a failing write leaves the previous checkpoint intact and no litter
    before = ckpt.read_bytes()
    bad = {"last_date": "2025-04-02", "rows": 1_300_000, "cursor": object()}
    try:
        write_checkpoint(ckpt, bad)
    except TypeError:
        pass
    else:
        raise AssertionError("unserialisable state accepted")
    assert ckpt.read_bytes() == before
    assert sorted(os.listdir(d)) == ["gst_backfill.json"], os.listdir(d)

    # the replace happens from the same directory (same filesystem => atomic)
    calls = []
    real_replace = os.replace

    def spy_replace(src, dst, *a, **kw):
        calls.append((os.path.dirname(os.path.abspath(src)), os.path.abspath(dst)))
        return real_replace(src, dst, *a, **kw)

    os.replace = spy_replace
    try:
        write_checkpoint(ckpt, {"last_date": "2025-04-03"})
    finally:
        os.replace = real_replace
    assert calls == [(os.path.abspath(d), os.path.abspath(ckpt))], calls
    assert json.loads(ckpt.read_text(encoding="utf-8")) == {"last_date": "2025-04-03"}
    assert sorted(os.listdir(d)) == ["gst_backfill.json"]

A common wrong solution (the tests catch it):

import json
import os
from typing import Any


def write_checkpoint(path: str | os.PathLike[str], state: dict[str, Any]) -> None:
    # "with" closes the file, so this looks safe...
    with open(path, "w", encoding="utf-8") as f:
        json.dump(state, f, ensure_ascii=False, sort_keys=True, indent=1)

Alternatives and tradeoffs:

  • open(path, 'w') + json.dump (the obvious answer): 'w' truncates the existing file immediately, and json.dump writes in chunks, so a crash or a serialisation error mid-way leaves an empty or partial file and destroys the last good checkpoint.
  • Serialise first, then open('w').write(payload): fixes the serialisation-error case but not a crash during the write or an unflushed buffer at power loss.
  • Keep two generations (ckpt.json and ckpt.json.prev, or numbered files) and fall back to the previous one if the newest is unreadable: useful on filesystems without atomic rename (some network or object-store mounts).
  • Store the checkpoint in the database in the same transaction as the processed output: strictly better when the output already goes to a DB, because the checkpoint and the data can never disagree.

Follow-ups:

  1. "The checkpoint is written after the day's output is written. A crash between the two happens. Is the backfill still correct on resume?" It reprocesses that day, so the day's output write must be idempotent: overwrite the day's partition (delete-and-insert in one transaction, or INSERT ... ON CONFLICT DO UPDATE), never blind-append. Checkpoint-after-output gives at-least-once; idempotent output turns it into effectively-once.
  2. "This now runs on S3/GCS instead of a local disk. What changes?" There is no rename; an object PUT is already atomic (readers see the old or new object, never partial), so you write the object directly. But you lose cross-object atomicity, and you may want conditional writes (generation/ETag preconditions) so two runs of the job can't overwrite each other's checkpoint.
  3. "mkstemp creates the temp file with mode 0600. Does that matter?" After os.replace, the checkpoint has the temp file's permissions and owner, not the old file's. If another user or a sidecar needs to read it, os.chmod the temp file (or copy the old mode with shutil.copymode) before the replace.

Scoring: Strong: Temp file in the same directory, fsync, os.replace, cleanup on failure, serialise before touching disk; explains why 'w' truncation is the bug and mentions directory fsync and idempotent output on resume. Acceptable: Gets temp-plus-rename, but puts the temp file in /tmp (possibly another filesystem, where the rename fails or isn't atomic) or forgets cleanup; corrects with a hint. Red flag: Believes the with block makes the write atomic, or proposes try/except around json.dump to rewrite the old content.

SR-ET-C03 · Verify a signed webhook from a payment provider

Easy & Tricky · Coding · Senior (5–8 years) · security, HMAC, webhooks, constant-time comparison, replay protection, raw bytes vs parsed JSON · 15 min

Problem:

Your service receives payment-status webhooks. Each request carries a header

X-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>

where the HMAC is computed with your shared secret over the bytes b"<t>." + raw_request_body. During secret rotation the provider sends several v1= entries (one per active secret); the request is valid if any of them matches.

Write verify_webhook. Return True only if the header is well-formed, the timestamp is within tolerance_s seconds of now (in either direction), and a signature matches. Return False for everything else; never raise on bad input.

Signature: def verify_webhook(raw_body: bytes, header: str, secret: bytes, *, now: int, tolerance_s: int = 300) -> bool:

Constraints: Standard library only. The body is JSON with arbitrary key order, whitespace and UTF-8 text (e.g. "₹499 refund"). Treat the header as attacker-controlled.

Examples:

  • verify_webhook(body, 't=1767225600,v1=<correct hex>', secret, now=1767225630) → True
  • same request, now=1767226200 (10 minutes later) → False (stale: possible replay)
  • body with one byte changed → False

Hints:

  1. Your web framework hands you request.json(). Is that what the provider signed?
  2. How does == on two strings behave timing-wise, and why does it matter for a signature check?
  3. What stops an attacker from re-sending a captured valid request tomorrow?

Reference solution:

import hashlib
import hmac


def verify_webhook(
    raw_body: bytes,
    header: str,
    secret: bytes,
    *,
    now: int,
    tolerance_s: int = 300,
) -> bool:
    """Check a `t=...,v1=...` HMAC-SHA256 signature over b"<t>." + raw_body.

    Signs the exact bytes received (never re-serialised JSON), compares in
    constant time, accepts any of several v1 signatures (secret rotation), and
    rejects timestamps outside the tolerance window (replay protection).
    """
    timestamp: int | None = None
    candidates: list[str] = []
    for part in header.split(","):
        name, sep, value = part.strip().partition("=")
        if not sep:
            return False
        if name == "t":
            if timestamp is not None or not (value.isascii() and value.isdecimal()):
                return False
            timestamp = int(value)
        elif name == "v1":
            candidates.append(value)
    if timestamp is None or not candidates:
        return False
    if abs(now - timestamp) > tolerance_s:
        return False

    signed = str(timestamp).encode("ascii") + b"." + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, c) for c in candidates if c.isascii())

Complexity: O(len(body)) for one HMAC computation plus O(k) constant-time comparisons for k candidate signatures. Header parsing is O(len(header)). No allocation beyond the signed byte string.

Tests:

import hashlib
import hmac

SECRET = b"whsec_test_7f3a"
OLD_SECRET = b"whsec_old_19c2"
T = 1_767_225_600

def sign(body: bytes, t: int, secret: bytes = SECRET) -> str:
    return hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest()

# exactly as the provider sent it: compact separators, raw UTF-8 (not \u escapes)
body = '{"event":"refund.processed","amount_paise":49900,"note":"₹499 refund"}'.encode("utf-8")
header = f"t={T},v1={sign(body, T)}"

assert verify_webhook(body, header, SECRET, now=T + 30) is True
assert verify_webhook(body, header, SECRET, now=T - 30) is True        # small clock skew
assert verify_webhook(body, f" t={T} , v1={sign(body, T)} ", SECRET, now=T) is True

# tampering and wrong secret
assert verify_webhook(body.replace(b"49900", b"99900"), header, SECRET, now=T) is False
assert verify_webhook(body, header, b"wrong", now=T) is False

# replay window
assert verify_webhook(body, header, SECRET, now=T + 301) is False
assert verify_webhook(body, header, SECRET, now=T + 300) is True

# rotation: any v1 may match
rotating = f"t={T},v1={sign(body, T, OLD_SECRET)},v1={sign(body, T)}"
assert verify_webhook(body, rotating, SECRET, now=T) is True
assert verify_webhook(body, rotating, OLD_SECRET, now=T) is True

# malformed headers never raise
for bad in ["", "garbage", f"v1={sign(body, T)}", f"t={T}", f"t=abc,v1={sign(body, T)}",
            f"t={T},t={T},v1={sign(body, T)}", f"t={T},v1=", f"t={T},v1=ä{sign(body, T)[1:]}",
            f"t=-{T},v1={sign(body, T)}", f"t=²,v1={sign(body, T)}"]:
    assert verify_webhook(body, bad, SECRET, now=T) is False, bad

A common wrong solution (the tests catch it):

import hashlib
import hmac
import json


def verify_webhook(raw_body, header, secret, *, now, tolerance_s=300):
    try:
        parts = [p.strip().partition("=") for p in header.split(",")]
        t = int(next(v for k, _, v in parts if k == "t"))
        sigs = [v for k, _, v in parts if k == "v1"]
    except (StopIteration, ValueError):
        return False
    if not sigs or abs(now - t) > tolerance_s:
        return False
    # "normalise" the payload first, as the framework already parsed it
    canonical = json.dumps(json.loads(raw_body)).encode()
    expected = hmac.new(secret, f"{t}.".encode() + canonical, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs if s.isascii())

Alternatives and tradeoffs:

  • Re-serialising the parsed JSON (the obvious answer when the framework gives you a dict): json.dumps changes separators, escapes non-ASCII to ₹, and may reorder or reformat numbers, so valid requests fail. Teams then "fix" it by disabling verification. Always read the raw body (await request.body() in FastAPI/Starlette) before parsing.
  • expected == candidate: functionally correct but short-circuits on the first differing character, leaking timing information an attacker can use to forge a signature byte by byte. hmac.compare_digest takes time independent of where the strings differ.
  • Provider SDK helper: prefer it when one exists; this exercise is what those helpers do, and you still need to know to pass them raw bytes.
  • Dedupe by event ID in addition to the timestamp window: the window limits replays to 5 minutes; storing processed event IDs makes a replay within the window harmless too.

Follow-ups:

  1. "In FastAPI, the endpoint declares payload: RefundEvent as a Pydantic body parameter. How do you get the raw bytes?" Take request: Request and call raw = await request.body() first, verify, and only then RefundEvent.model_validate_json(raw). Or do verification in a dependency that reads the body (Starlette caches it, so later parsing still works). Don't verify inside a model validator: by then you only have parsed data.
  2. "Why compare hex strings with compare_digest rather than decoding to bytes first? Does it matter?" Either works if both sides are the same type: compare_digest accepts two ASCII str or two bytes-like objects. Comparing hex is simpler and avoids bytes.fromhex raising on malformed input. The point is a constant-time comparison of equal-length values; a length mismatch returns False quickly, which leaks only the length, and that is public anyway.
  3. "How would you roll your own secret over without dropping webhooks?" Accept a list of secrets in config (current plus previous) and verify against each, which this function supports by being called per secret or by taking a list. Rotate the provider to sign with both, deploy the new secret, then remove the old one after the overlap window. Alert on verification failures by reason (stale, bad signature, malformed) so a misconfiguration is visible immediately.

Scoring: Strong: Signs the raw bytes and says why parsing breaks it, uses hmac.compare_digest, enforces the timestamp window in both directions, handles multiple v1 values, and never raises on hostile headers. Mentions event-ID dedupe and reading the raw body in FastAPI. Acceptable: Correct HMAC over raw bytes with compare_digest, but misses rotation or the replay window until asked. Red flag: Signs json.dumps(request.json()), compares with == without knowing why that's a problem, or wraps everything in except Exception: return True.

SR-ET-D01 · "Make it async": a slow invoice-PDF endpoint

Easy & Tricky · Design · Senior (5–8 years) · concurrency, asyncio vs threads vs processes, GIL, CPU-bound work, job queues, profiling · 30 min

Scenario:

A B2B platform's FastAPI service has POST /invoices/{id}/pdf, which renders a GST invoice as PDF (Jinja2 HTML template, then a pure-Python PDF library, plus a QR code) and returns it. Each render takes ~900 ms of CPU for a 3-page invoice and up to 8 s for 200-line invoices. At month-end, customers download ~40 invoices/s for about two hours, p99 latency hits 30 s, health checks time out, and Kubernetes restarts pods, making it worse.

The service runs Uvicorn with 1 worker process per pod on 4-vCPU pods; the endpoint is async def and calls render_pdf(invoice) directly.

A teammate's PR: "Rewrite the PDF rendering to use asyncio and aiofiles so the endpoint is non-blocking." Review the proposal and design the fix.

Clarifying questions:

  • {'Is the time really CPU? Has anyone profiled a render? (Yes': "py-spy shows ~85% in the PDF library's layout code, ~10% template rendering, ~5% DB and file I/O.)"}
  • Do users need the PDF synchronously, or would "your invoice is ready" plus a download link work? (Interactive downloads want it within a few seconds; bulk "download all March invoices" can be async.)
  • How often is the same invoice re-downloaded? Do invoices change after they're issued? (Issued invoices are immutable; most downloads are repeats of the same few thousand invoices at month-end.)
  • What's the pod budget? Can we add a separate worker deployment?
  • {'Are there other endpoints on the same service that are suffering? (Yes': 'the order-status API on the same pods also times out at month-end.)'}

Reference design:

Why "make it async" is the wrong fix. asyncio gives concurrency for code that waits; it does nothing for code that computes. The render is ~85% CPU in Python, which holds the GIL. Worse, today the CPU-bound call runs inside an async def endpoint, so it blocks the event loop: while one PDF renders, that worker can't accept requests, answer health checks or serve the order-status API. That is why pods get restarted. aiofiles would make the 5% file I/O non-blocking and change nothing else.

Immediate fix (hours): stop blocking the event loop. Either declare the endpoint as plain def (FastAPI runs it in its threadpool) or await anyio.to_thread.run_sync(render_pdf, invoice) / asyncio.to_thread. This restores health checks and other endpoints, but threads still share one GIL, so PDF throughput stays at about one CPU's worth per process.

Real fix: use the cores, and stop rendering the same thing twice. 1. Render once, store, serve many. Issued invoices are immutable, so render on issue (or on first request) and store the PDF in object storage keyed by invoice_id and template version; downloads become a redirect to a pre-signed URL. Most month-end traffic turns into cheap reads. 2. Move rendering to a separate worker pool (Celery/RQ/Arq, or a dedicated deployment consuming a queue), sized by CPU: one process per vCPU. The API enqueues and returns 202 with a job ID for bulk requests; for interactive single downloads it waits a bounded time (e.g. 5 s) for the job, then falls back to "we'll email you". 3. If a separate deployment is overkill, a ProcessPoolExecutor in the API process (loop.run_in_executor(pool, render_pdf, invoice_id)) uses multiple cores, but competes with request handling for CPU and memory and needs care with pool breakage and pickling (send the ID, not the ORM object). 4. Profile the render itself: py-spy or cProfile on a 200-line invoice. Often the template does N+1 DB queries, or fonts and the QR library are re-initialised per render; caching fonts per worker can halve the time.

Protect the service: a concurrency limit on rendering (semaphore or queue length) with 429/503 + Retry-After beyond it, liveness probes that don't depend on the CPU-bound path, and month-end pre-rendering of the previous month's invoices overnight.

Trade-offs:

  • Sync endpoint in threadpool vs process pool vs separate workers. Threadpool: zero infra, unblocks the loop, no extra throughput. Process pool in the API: real parallelism, but the API pod's CPU now serves two workloads and a crashed worker breaks the pool. Separate worker deployment: scales independently, isolates failures, adds a queue to operate. With month-end spikes and an existing queue, separate workers win.
  • Pre-render vs render on demand. Pre-rendering costs storage and compute for invoices nobody downloads, but turns the peak into reads; render-on-first-request plus storage is a good middle ground.
  • Synchronous response vs 202 + polling or notification. Synchronous is simpler for clients; async scales and survives spikes. Use both: short wait, then fall back.
  • More Uvicorn workers per pod (e.g. 4 processes on 4 vCPUs) is a cheap partial fix: real parallelism, but each worker's event loop is still blocked while it renders unless the render is offloaded too.

Deep-dive probes:

  • "Prove the event loop is blocked." Look for: asyncio debug mode (PYTHONASYNCIODEBUG=1, slow-callback warnings above loop.slow_callback_duration), a loop-lag metric (schedule a callback every 100 ms and measure delay), py-spy dump showing the main thread in the PDF library, and health-check latency correlating with renders.
  • "Why not just use threads instead of processes for rendering?" Look for: the GIL serialises Python bytecode, so threads don't add CPU throughput for pure-Python work; they help only when the library releases the GIL in C code. Free-threaded CPython builds (experimental in 3.13, officially supported but not the default from 3.14) need every C extension in the stack to support them; not a quick production fix.
  • "The worker pool is behind by 20 minutes at month-end." Look for: queue depth and age as the scaling signal (KEDA or similar), priorities for interactive over bulk jobs, idempotent jobs keyed by invoice ID so retries and duplicate requests don't re-render, and pre-rendering to shrink the peak.
  • "Template change: last year's invoices must still render as issued." Look for: storing the rendered PDF as the legal record (immutable, versioned key), and not re-rendering historical invoices with a new template.

Rubric: Strong: Rejects async as a fix for CPU-bound work and explains the GIL and event-loop blocking; separates the immediate fix (stop blocking the loop) from the real fix (process-level parallelism plus render-once-and-store); uses profiling data to decide; adds admission control and queue-based scaling. Acceptable: Recognises the work is CPU-bound and proposes a process pool or more workers, but misses that the async def endpoint is blocking the event loop, or misses caching immutable PDFs, until prompted. Red flag: Approves the asyncio/aiofiles rewrite, proposes threads as the throughput fix, or answers only with 'add more pods'.

SR-ET-D02 · Renaming a column and an event field without downtime

Easy & Tricky · Design · Senior (5–8 years) · migrations, expand/contract, rolling deploys, backward compatibility, data contracts, Alembic · 30 min

Scenario:

The orders service (FastAPI + SQLAlchemy + Alembic on PostgreSQL, 10 pods with rolling deploys) has a column orders.amount holding the order total in rupees as NUMERIC(12,2). Finance wants it stored as integer paise in a column named amount_paise, and the service also publishes an order.created event (JSON on Kafka) with field amount that four other teams consume, including a Spark job and a Go service.

A developer's plan: one PR with an Alembic migration that renames the column and converts the values (ALTER TABLE orders RENAME COLUMN amount TO amount_paise; UPDATE orders SET amount_paise = amount_paise * 100; ALTER ... TYPE BIGINT), updates the model and the event field, runs the migration in the deploy pipeline, and ships. "It's a rename; it'll take a minute."

orders has 180 million rows and takes ~300 writes/s. Review the plan and design the rollout.

Clarifying questions:

  • How do deploys work? Do old and new pods run at the same time during a rollout, and does the migration run before or after the new pods start? (Rolling deploy over ~10 minutes; migration runs as a pre-deploy job.)
  • Can we roll back the application quickly if something breaks, and is the migration expected to be reversible?
  • Who consumes order.created, and do we know their deploy schedules? Is there a schema registry or versioned contract?
  • Are there readers of the orders table other than this service (BI tools, reporting replicas, CDC into the warehouse)?
  • Are there amounts with fractions of a paisa anywhere, or values that would overflow?
  • What's the lock-timeout and statement-timeout policy on this database?

Reference design:

Why the one-step plan fails. During a rolling deploy, old and new code run at the same time. After the migration, old pods still query orders.amount, which no longer exists, so every request on them errors until the rollout finishes. Rolling back the app doesn't help, because the schema moved. The single UPDATE of 180M rows holds row locks for a long time, generates a huge amount of WAL, and the ALTER ... TYPE rewrites the table under an ACCESS EXCLUSIVE lock, blocking all reads and writes for minutes. Renaming the event field breaks four consumers who deploy on their own schedules: the Go service drops orders with a missing amount, and the Spark job silently reads nulls.

Expand / migrate / contract, each step deployable and reversible on its own:

  1. Expand (schema). ALTER TABLE orders ADD COLUMN amount_paise BIGINT NULL (a nullable column with no default is a metadata-only change, near-instant; run with a short lock_timeout and retry so it doesn't queue behind long transactions and block traffic).
  2. Dual write (code). Deploy code that writes both amount and amount_paise on every insert or update (converting with Decimal, int(amount * 100) after checking there's no fractional paisa), and still reads amount. Old pods that write only amount are still around for a few minutes, so step 3 must catch their rows too.
  3. Backfill in batches. A Python job updates WHERE amount_paise IS NULL AND id BETWEEN ? AND ? in batches of ~10k rows, committing each batch, throttled (sleep between batches, monitor replication lag), resumable from the last ID, and idempotent (re-running is harmless). Re-run until no NULLs remain after the dual-write rollout has finished.
  4. Verify. SELECT count(*) WHERE amount_paise IS NULL OR amount_paise <> amount * 100 returns 0; then add the constraint without a long lock: ADD CONSTRAINT ... CHECK (amount_paise IS NOT NULL) NOT VALID, then VALIDATE CONSTRAINT.
  5. Switch reads (code). Read amount_paise behind a feature flag; compare in shadow mode for a while if the risk justifies it.
  6. Contract. Once no code reads or writes amount (and BI and CDC consumers have moved), stop writing it, then drop it in a later release.

Event contract, same pattern. Add amount_paise to order.created alongside amount (additive change; consumers ignore unknown fields), announce a deprecation date, track which consumers have migrated (consumer lag on reading the new field, or just asking the four teams), and remove amount only after all have switched, ideally as a new event version (order.created.v2) if the registry supports it. Contract tests in CI check that the event still validates against the published schema.

Every step can be rolled back without data loss, because the old column stays correct until the very end.

Trade-offs:

  • Expand/contract vs a maintenance window. A 20-minute window with the one-step plan is simpler and sometimes acceptable for small internal tables; for a 180M-row, 300 writes/s table with external consumers, the window would be long and the rollback story poor.
  • Dual-write in application code vs a database trigger. Triggers catch every writer (including old pods and ad-hoc scripts) and remove the race in step 2, but they hide logic in the database and add per-write cost; app dual-write is explicit and testable. A trigger for the transition period is a reasonable choice here because old pods keep writing during the rollout.
  • Backfill speed vs production impact. Bigger batches finish sooner but hold locks longer and spike replication lag; throttle against lag and p99 latency, not a fixed rate.
  • Keeping the old event field for months costs a little payload size and some confusion; removing it early breaks consumers you don't control.

Deep-dive probes:

  • "Between steps 2 and 3, an old pod inserts an order with only amount." Look for: understanding that rolling deploys overlap, so the backfill (or a trigger) must cover rows written by old code; the verification query catches stragglers before the read switch.
  • "The ADD COLUMN migration hung and the site went down for 4 minutes." Look for: the ACCESS EXCLUSIVE lock request queued behind a long-running transaction (an analytics query), and every subsequent query queued behind the lock request. Fix: SET lock_timeout = '2s' with retries, and checking for long transactions before migrating.
  • "How do you test this in CI?" Look for: running the migrations against a production-like schema, a test that old code works against the expanded schema (N-1 compatibility), Alembic upgrade/downgrade tests, and contract tests for the event with fixtures of both old and new shapes.
  • "Which Python details matter in the conversion?" Look for: Decimal arithmetic (Decimal('19.99') * 100 is exactly 1999.00), never float(amount) * 100 (19.99 becomes 1998.9999999999998, and int() truncates it to 1998), asserting there's no fractional paisa, and BIGINT headroom.

Rubric: Strong: Identifies old/new code overlap during rolling deploys, table-rewrite locking and the external consumers as the three failure modes; lays out expand → dual write → batched idempotent backfill → verify → switch reads → contract, with each step independently reversible; handles the event with additive change plus deprecation; mentions lock timeouts and Decimal conversion. Acceptable: Proposes add-new-column, backfill, switch, drop, but misses the rolling-deploy overlap or the event consumers until prompted. Red flag: Approves the single migration, proposes renaming and fixing consumers 'at the same time', or backfills 180M rows in one UPDATE.

Medium (M)

SR-M-O01 · Mock, spec and autospec against a renamed parameter

Medium · Output · Senior (5–8 years) · testing strategy, unittest.mock, autospec, contract drift, test doubles · 8 min

Question: A refund notifier was written against a draft of the SMS gateway client in which the second parameter was called message. The released client calls it text. The team's unit test uses a mock gateway. What does this print for each of the three kinds of mock?

from unittest import mock


class SmsGateway:
    def send(self, phone: str, text: str, *, priority: str = "normal") -> str:
        raise RuntimeError("real network call")


def notify_refund(gateway, phone, amount):
    # Written against a draft API that called the second parameter `message`
    return gateway.send(phone, message=f"Refund of ₹{amount} processed", priority="high")


def check(gateway):
    try:
        status = notify_refund(gateway, "+919800000001", 499)
        gateway.send.assert_called_once()
        return f"PASS ({status})"
    except Exception as e:
        return f"{type(e).__name__}: {e}"


loose = mock.Mock()
loose.send.return_value = "queued"
print(f"{'Mock':10}", check(loose))

typed = mock.Mock(spec=SmsGateway)
typed.send.return_value = "queued"
print(f"{'spec':10}", check(typed))

strict = mock.create_autospec(SmsGateway, instance=True)
strict.send.return_value = "queued"
print(f"{'autospec':10}", check(strict))

print(f"{'.sendd?':10}", hasattr(loose, "sendd"), hasattr(typed, "sendd"), hasattr(strict, "sendd"))

Expected output:

Mock       PASS (queued)
spec       PASS (queued)
autospec   TypeError: missing a required argument: 'text'
.sendd?    True False False

Explanation:

Three test doubles, three different levels of checking:

  • Mock() accepts any attribute and any call. loose.send(...) with a keyword the real method doesn't have is recorded happily, assert_called_once() passes, and the test is green while production would raise TypeError on the first refund.
  • Mock(spec=SmsGateway) restricts attribute names to those that exist on the class, so typed.sendd is an AttributeError (hence False on the last line). But the child typed.send is still a plain Mock with no signature: the bad keyword passes. This is the half-measure many teams think is "safe".
  • create_autospec(SmsGateway, instance=True) copies the signatures recursively. Calling send(phone, message=..., priority=...) is checked against send(self, phone, text, *, priority="normal") and fails with the same TypeError the real object would raise: missing a required argument: 'text'. instance=True makes it model an instance, so self is bound and not counted.

Why a senior cares: the bug here is contract drift between two units, and it is exactly what a mocked unit test is supposed to protect you from. A mock that doesn't know the contract gives you coverage numbers without protection. Team standard: autospec=True (or create_autospec) by default for collaborators you own, plus a small number of contract or integration tests against the real client (or a fake that the client's owners maintain).

Takeaway: spec checks names, autospec checks names and call signatures; a plain Mock checks nothing.

Follow-ups:

  1. "Why isn't autospec the default in unittest.mock?" It is slower (it introspects the spec object, recursively, on first attribute access) and it can't see attributes created in __init__ (instance attributes aren't on the class), so some tests need extra setup. Those costs are small compared to tests that silently pass; most teams make autospec=True the default in mock.patch and opt out deliberately.
  2. "Autospec now passes. Can this code still break in production against the real gateway?" Yes. Autospec only checks the shape of the call: names, arity, keywords. It knows nothing about behaviour: the real gateway might reject numbers without +91, cap messages at 160 characters, or return "QUEUED" instead of "queued". That's what a contract test (run against the provider's sandbox, or a verified fake shared by both teams) or a consumer-driven contract is for. Mocks prove you called something correctly; they can't prove the something does what you assume.
  3. "You're setting testing standards for a team of eight. Where do you draw the line between mocking and using real dependencies?" Mock at boundaries you don't own and can't run cheaply (payment gateways, SMS, third-party HTTP), always with autospec or a typed fake. Use the real thing when it's cheap and deterministic: an in-memory or containerised Postgres/SQLite for repository code, a real FastAPI TestClient for request handling. Don't mock your own pure functions or data classes. Add a thin layer of contract tests per external provider, and keep a handful of end-to-end smoke tests in CI against sandboxes.

Scoring: Strong: All four lines right; explains that spec restricts attribute names but not call signatures and that autospec binds signatures (including why instance=True matters for self); connects this to contract drift and proposes a team standard plus contract tests for what autospec can't catch. Acceptable: Gets the Mock and autospec lines right but assumes spec= also checks the signature; corrects it once asked what typed.send is. Red flag: Believes a passing mocked test proves the integration works, or says the Mock line raises because message is wrong; can't name any way to make mocks check the real interface.

SR-M-O02 · A renamed field slips through a lenient event contract

Medium · Output · Senior (5–8 years) · data contracts, pydantic v2, schema evolution, extra fields, defaults · 8 min

Question: Your service consumes OrderEvent messages from the orders team's Kafka topic. Their v2 producer renamed amount_paise to amount_inr without telling you. Your consumer validates every message with Pydantic. What does this print?

from pydantic import BaseModel, ConfigDict, ValidationError


class OrderEvent(BaseModel):                  # the consumer's copy of the contract
    order_id: str
    amount_paise: int = 0
    is_cod: bool = False


class OrderEventV2(BaseModel):                # same fields, tightened
    model_config = ConfigDict(extra="forbid")
    order_id: str
    amount_paise: int
    is_cod: bool = False


v1 = {"order_id": "ORD-9001", "amount_paise": "149900", "is_cod": "no"}
v2 = {"order_id": "ORD-9001", "amount_inr": 1499.0, "is_cod": "yes"}   # producer renamed a field

for model in (OrderEvent, OrderEventV2):
    for name, payload in [("v1", v1), ("v2", v2)]:
        try:
            print(model.__name__, name, "->", model.model_validate(payload))
        except ValidationError as err:
            print(model.__name__, name, "->", sorted((d["type"], d["loc"][0]) for d in err.errors()))

Expected output:

OrderEvent v1 -> order_id='ORD-9001' amount_paise=149900 is_cod=False
OrderEvent v2 -> order_id='ORD-9001' amount_paise=0 is_cod=True
OrderEventV2 v1 -> order_id='ORD-9001' amount_paise=149900 is_cod=False
OrderEventV2 v2 -> [('extra_forbidden', 'amount_inr'), ('missing', 'amount_paise')]

Explanation:

Lax mode coerces. By default Pydantic v2 validates in lax mode: the numeric string "149900" becomes the int 149900, and the strings "no" / "yes" become False / True (Pydantic accepts yes/no, true/false, on/off, 1/0 for booleans). So v1 parses as you'd hope.

Unknown fields are ignored and defaults fill the gap. The model's default config is extra="ignore". In v2 the key amount_inr is unknown, so it is silently dropped; amount_paise is missing, so its default 0 is used. The result is a valid OrderEvent saying the order is worth ₹0. Nothing raises, nothing logs, and the revenue dashboard quietly drops to zero for every v2 order.

The tightened model fails loudly. OrderEventV2 makes amount_paise required (no default) and sets extra="forbid". Now v2 produces two errors, missing for amount_paise and extra_forbidden for amount_inr, which is exactly the diagnosis you want in the dead-letter queue. v1 still parses, so tightening didn't break the existing producer.

The design lesson is about defaults on business-critical fields: a default turns "the producer didn't send it" into "the producer sent zero". Use defaults only where absence genuinely means that value.

Takeaway: in a consumer, extra="ignore" plus a default on a required business field is how a field rename becomes silent data corruption.

Follow-ups:

  1. "Isn't extra="forbid" too brittle? The producer adding any new field would now break your consumer." Yes, and that's the real trade-off. Forward compatibility normally wants consumers to tolerate new fields. A common middle ground: keep extra="ignore" (or "allow") but make business fields required with no defaults, and log or count unknown fields (model_extra with extra="allow") so a rename shows up on a dashboard instead of in the revenue numbers. Where the topic has a schema registry (Avro/Protobuf/JSON Schema with compatibility checks), enforce it there at publish time, so a breaking rename can't be produced at all.
  2. "Would strict=True have caught this?" Not the rename: strict mode controls type coercion, not unknown or missing fields. With strict mode, "149900" and "no" in v1 would also be rejected, which breaks the existing producer if it sends JSON strings. Strict is a separate decision about how much coercion you trust; the rename is caught by required fields and by the extra setting.
  3. "How would you roll out the rename properly if you owned both sides?" Expand and contract: the producer sends both amount_paise and amount_inr for a period; consumers move to the new field (accepting either, preferring the new one, via a validator or AliasChoices); once every consumer is on the new field (checked through metrics or the registry), the producer drops the old one. Version the event (schema_version) so consumers can branch explicitly when semantics change, not just names.

Scoring: Strong: All four lines exact; explains lax coercion of "149900" and "yes", that extra="ignore" drops amount_inr and the default supplies 0; argues against defaults on business fields and discusses the forward-compatibility trade-off of forbid (registry, metrics on unknown fields, expand/contract). Acceptable: Gets the v2 silent-zero line and the two errors, but is unsure about the "no" / "yes" coercion, or proposes extra="forbid" everywhere without noticing the forward-compatibility cost. Red flag: Expects Pydantic to raise on the unknown field by default, or says v2 raises a type error; doesn't see why a ₹0 order passing validation is a production incident.

SR-M-O03 · Reading cProfile call counts before and after caching

Medium · Output · Senior (5–8 years) · profiling, cProfile, pstats, recursion, lru_cache, N+1 calls · 10 min

Question: A nightly sales report got slow. You profile it, then wrap the two helper functions in lru_cache and profile again. The script prints only the ncalls column that pstats would show, so the numbers are deterministic. What does it print?

import cProfile
import pstats
from functools import lru_cache

PARENT = {"phones": "electronics", "electronics": None, "kurtas": "fashion", "fashion": None}
RATES = {"USD": 83.0, "AED": 22.6, "INR": 1.0}


def fx_rate(currency):                 # in production: an HTTP call to the rates service
    return RATES[currency]


def category_path(cat):                # recursive walk up the category tree
    parent = PARENT[cat]
    return cat if parent is None else category_path(parent) + "/" + cat


def report(orders):
    rows = []
    for amount, currency, cat in orders:
        rows.append((amount * fx_rate(currency), category_path(cat)))
    return sorted(rows, key=lambda r: r[0], reverse=True)


orders = [(10, "USD", "phones"), (500, "INR", "kurtas"), (40, "AED", "phones"), (7, "USD", "kurtas")] * 250


def profile(fn):
    prof = cProfile.Profile()
    prof.runcall(fn, orders)
    # pstats keys: (file, line, name) -> (primitive calls, total calls, tottime, cumtime, callers)
    for (_, _, name), (primitive, total, *_) in sorted(pstats.Stats(prof).stats.items()):
        if name in ("report", "fx_rate", "category_path", "<lambda>"):
            ncalls = str(total) if total == primitive else f"{total}/{primitive}"
            print(f"  {name:14} ncalls {ncalls}")


print("before:")
profile(report)

fx_rate = lru_cache(maxsize=None)(fx_rate)
category_path = lru_cache(maxsize=None)(category_path)
print("after:")
profile(report)

Expected output:

before:
  fx_rate        ncalls 1000
  category_path  ncalls 2000/1000
  report         ncalls 1
  <lambda>       ncalls 1000
after:
  fx_rate        ncalls 3
  category_path  ncalls 4/2
  report         ncalls 1
  <lambda>       ncalls 1000

Explanation:

The lines come out in source order because the stats are sorted by (file, line, name).

Before caching. There are 1,000 orders.

  • fx_rate runs once per order: 1000. In production that is 1,000 HTTP calls for 3 distinct currencies, the classic N+1 shape.
  • category_path is called 1,000 times from report, and each of those makes one recursive call for the parent (phones → electronics, kurtas → fashion). That's 2,000 calls in total, of which 1,000 are primitive, meaning not made from inside another active call of the same function. pstats prints this as total/primitive: 2000/1000. When you see a split ncalls, the function is recursive, and the second number is how often it was entered from outside.
  • The sort key lambda runs once per element (1,000), not once per comparison. sorted(key=...) computes each key once and then compares the keys.

After caching. lru_cache wraps the original functions and the module-level names are rebound to the wrappers. cProfile counts calls of the original Python functions, which now run only on cache misses.

  • fx_rate: three distinct currencies give three misses: 3.
  • category_path: the recursive call inside the function body looks up the global name category_path, which is now the cached wrapper. So the recursion is cached too. Misses are phones (primitive) → electronics (recursive), and kurtas (primitive) → fashion (recursive): 4/2.
  • The lambda and report are unchanged.

Takeaway: in a profile, read ncalls before tottime. A call count proportional to the input for something that depends on far fewer distinct values points to an N+1 problem, and caching or batching fixes it.

Follow-ups:

  1. "The rates actually change during the day. Is lru_cache(maxsize=None) on fx_rate acceptable in a long-running service?" No. It never expires and never bounds memory. In a nightly batch job, caching for the duration of one run is exactly right (rates are snapshotted per report, which is also good for reproducibility). In a service, use a TTL cache keyed by currency, or better, fetch all rates once per request or batch (get_rates([...])), which is the batching fix for N+1 and also makes the report internally consistent.
  2. "cProfile showed the problem here, but in production you can't run cProfile on the API pods. What do you use?" A sampling profiler such as py-spy (py-spy top --pid / py-spy record -o flame.svg) attaches to a running process with low overhead and without code changes; austin or scalene are alternatives. cProfile is deterministic tracing and adds large overhead to call-heavy code, which skews results toward small functions. For I/O-bound N+1 issues, distributed tracing (OpenTelemetry spans per outbound call) shows the 1,000 HTTP calls directly.
  3. "Why might tottime mislead you in the before profile if fx_rate really made an HTTP call?" cProfile measures wall-clock time per function, so time spent blocked on the network is attributed to whichever function made the socket call (recv inside the HTTP client), and fx_rate shows up in cumtime rather than tottime. Look at cumtime and the call counts to find the culprit, and remember cProfile's own overhead inflates functions that are called millions of times.

Scoring: Strong: Exact output, including 2000/1000 and 4/2; explains primitive vs total calls, that the sort key runs once per element, and that the recursive call goes through the rebound global name; turns this into the N+1 diagnosis and discusses TTL/batching and sampling profilers for production. Acceptable: Gets the before block and fx_rate 3 right but misses that the recursion is also cached (answers category_path 2/2 or 4), and fixes it once asked what name the function body looks up. Red flag: Assumes the key function is called once per comparison (n log n), or can't explain what the a/b form of ncalls means; treats profiling as reading tottime only.

SR-M-O04 · An idempotent upsert that a backfill still corrupts

Medium · Output · Senior (5–8 years) · idempotency, backfills, upsert, sqlite3, versioning, data pipelines · 8 min

Question: A wallet-balance table is fed by a live stream of account snapshots, each with a monotonically increasing version. An old day's file is replayed by a backfill job, and the backfill job itself gets retried. Both use an upsert. What does this print?

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE balance (account TEXT PRIMARY KEY, rupees INTEGER, version INTEGER)")

UPSERT = """
INSERT INTO balance (account, rupees, version) VALUES (?, ?, ?)
ON CONFLICT(account) DO UPDATE SET rupees = excluded.rupees, version = excluded.version
"""
GUARDED = UPSERT + " WHERE excluded.version > balance.version"

live = [("ACC-1", 1000, 1), ("ACC-1", 1500, 2), ("ACC-2", 300, 1), ("ACC-1", 900, 3)]
backfill = [("ACC-1", 1500, 2), ("ACC-2", 300, 1), ("ACC-2", 250, 2)]   # replay of an old day's file

for name, sql in [("plain", UPSERT), ("guarded", GUARDED)]:
    db.execute("DELETE FROM balance")
    for row in live:
        db.execute(sql, row)
    for row in backfill:
        db.execute(sql, row)
    for row in backfill:                        # the backfill job is retried
        db.execute(sql, row)
    print(name, db.execute("SELECT * FROM balance ORDER BY account").fetchall())

cur = db.execute(GUARDED, ("ACC-1", 1000, 1))
print("rowcount for a stale replay:", cur.rowcount)

Expected output:

plain [('ACC-1', 1500, 2), ('ACC-2', 250, 2)]
guarded [('ACC-1', 900, 3), ('ACC-2', 250, 2)]
rowcount for a stale replay: 0

Explanation:

Plain upsert: last write wins. ON CONFLICT ... DO UPDATE SET col = excluded.col overwrites with whatever row arrives last. After the live stream, ACC-1 is at version 3 (₹900). The backfill then replays version 2 (₹1500) and overwrites it: the balance goes back in time. ACC-2 ends at version 2 (₹250), which happens to be right, because the backfill also contained its newest row.

Note that the plain upsert is idempotent: running the backfill twice gives the same table as running it once. That is the trap. Idempotency only says "repeating this is harmless". It doesn't say "applying this out of order is harmless", and backfills, retries and replays are out of order by nature.

Guarded upsert: monotonic. With WHERE excluded.version > balance.version, the DO UPDATE runs only when the incoming row is newer. Stale rows become no-ops, so ACC-1 stays at version 3. Inserts of new keys are unaffected, because the WHERE belongs to the conflict branch only.

Rowcount. A stale replay hits the conflict branch, the guard is false, and no row changes: rowcount is 0. That's a useful metric: count "stale, skipped" rows per backfill so you can see how much of the replay was ignored.

Takeaway: make writes order-independent, not just repeatable. Compare a version or an event timestamp in the write itself (here in SQL, atomically), not in Python before the write.

Follow-ups:

  1. "The source doesn't have a version number, only an updated_at timestamp. Is guarding on that equally safe?" Mostly, with caveats: two updates in the same timestamp tick tie (use >= plus a tie-breaker such as an event ID or log sequence number, or accept last-arrival on ties); clocks on different producers can skew, so updated_at should come from a single authority (the source database's commit time or CDC log position), not from each app server's clock. A CDC log position (Postgres LSN, MySQL binlog position) is the best version you can get for free.
  2. "Why do the check inside the SQL statement instead of SELECT version in Python and then deciding whether to UPDATE?" Read-then-write is a race: two writers (the live consumer and the backfill) can both read version 2 and both write, and the slower one wins regardless of version. The conditional upsert is a single atomic statement, so the database serialises it on the row. The same idea in other stores: UPDATE ... WHERE version < :v, DynamoDB condition expressions, Redis WATCH/Lua.
  3. "The backfill also needs to delete accounts that were closed on that day. How does the guard extend to deletes?" Don't hard-delete. Write a tombstone (deleted = true with the event's version) through the same guarded upsert, so a late replay of an older 'update' can't resurrect the account. Physically purge tombstones later, once no replay can be older than them (after the backfill window or the retention period).

Scoring: Strong: Exact output; explains that the plain upsert is idempotent yet not order-safe, that the guard applies only to the conflict branch, why rowcount is 0; proposes atomic version guards, discusses updated_at ties and skew and tombstones for deletes. Acceptable: Gets the plain and guarded tables right but reasons only as 'the backfill overwrote it'; needs prompting to separate idempotency from ordering, or is unsure about the rowcount. Red flag: Expects the plain upsert to keep version 3 (thinks upsert 'merges' by recency), or proposes fixing it with a Python-side read-then-write and doesn't see the race.

SR-M-C01 · Circuit breaker for a flaky bank API

Medium · Coding · Senior (5–8 years) · resilience, circuit breaker, state machines, dependency injection, testability, thread safety · 30 min

Problem:

Your checkout service calls a partner bank's API. When the bank has an outage, every checkout waits for a 10-second timeout, the worker pool fills up, and the whole site goes down with it. Implement a circuit breaker:

  • closed (normal): calls go through. After failure_threshold consecutive failures, the circuit opens. A success resets the count.
  • open: calls fail immediately with CircuitOpenError, without calling the bank, until reset_timeout seconds have passed since the circuit opened.
  • half-open (after the timeout): exactly one trial call is let through. If it succeeds, the circuit closes. If it fails, the circuit opens again with a fresh timer. Other calls made while the trial is in flight fail fast.
  • Only exceptions listed in trip_on count as failures (default: any Exception). Other exceptions, such as a ValueError for bad input, propagate without changing the state. A tripping exception is re-raised to the caller unchanged.
  • Time comes from an injectable clock (default time.monotonic) so tests don't sleep.
  • state returns "closed", "open" or "half_open".

Multiple worker threads share one breaker per dependency.

Signature: class CircuitBreaker: def __init__(self, failure_threshold: int, reset_timeout: float, *, trip_on: tuple[type[BaseException], ...] = (Exception,), clock: Callable[[], float] = time.monotonic) -> None def call(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any @property def state(self) -> str

Constraints: Thread-safe for concurrent calls, but the lock must not be held while fn runs (the bank call can take seconds). Standard library only.

Examples:

  • cb = CircuitBreaker(3, 30, clock=clock); three consecutive ConnectionErrors from cb.call(bank.charge, 100) → cb.state == 'open'; the next cb.call raises CircuitOpenError without calling bank.charge
  • 30 seconds later (clock advanced), cb.call(bank.charge, 100) and the bank is back → returns the bank's result; cb.state == 'closed'
  • fail, fail, success, fail, fail with threshold 3 → still 'closed': failures must be consecutive

Hints:

  1. Write down the three states and every transition (what moves you from each state to each other state) before writing code.
  2. Store when the circuit opened rather than a separate 'half-open' flag; the state can be computed from the clock.
  3. How do you guarantee only one trial call in half-open when several threads arrive at once, without holding the lock during the call?
  4. Which exceptions should count? What if the caller passes a negative amount?

Reference solution:

import threading
import time
from typing import Any, Callable


class CircuitOpenError(RuntimeError):
    """Raised instead of calling the dependency while the circuit is open."""


class CircuitBreaker:
    """Stop calling a failing dependency; probe it again after a cool-down.

    closed    -> calls go through; `failure_threshold` consecutive tripping
                 failures open the circuit.
    open      -> calls fail fast with CircuitOpenError for `reset_timeout` seconds.
    half_open -> exactly one trial call is let through; success closes the
                 circuit, failure re-opens it with a fresh timer.
    """

    def __init__(self, failure_threshold: int, reset_timeout: float, *,
                 trip_on: tuple[type[BaseException], ...] = (Exception,),
                 clock: Callable[[], float] = time.monotonic) -> None:
        if failure_threshold < 1 or reset_timeout <= 0:
            raise ValueError("failure_threshold must be >= 1 and reset_timeout > 0")
        self._threshold = failure_threshold
        self._reset_timeout = reset_timeout
        self._trip_on = trip_on
        self._clock = clock
        self._lock = threading.Lock()
        self._failures = 0
        self._opened_at: float | None = None      # None means closed
        self._trial_in_flight = False

    @property
    def state(self) -> str:
        with self._lock:
            return self._state_locked()

    def _state_locked(self) -> str:
        if self._opened_at is None:
            return "closed"
        if self._clock() - self._opened_at >= self._reset_timeout:
            return "half_open"
        return "open"

    def call(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
        with self._lock:                           # decide, but never hold the lock during fn()
            state = self._state_locked()
            if state == "open" or (state == "half_open" and self._trial_in_flight):
                raise CircuitOpenError("circuit open; failing fast")
            is_trial = state == "half_open"
            if is_trial:
                self._trial_in_flight = True
        try:
            result = fn(*args, **kwargs)
        except self._trip_on:
            with self._lock:
                if is_trial:
                    self._trial_in_flight = False
                self._failures += 1
                if is_trial or self._failures >= self._threshold:
                    self._opened_at = self._clock()
            raise
        except BaseException:
            with self._lock:                       # non-tripping error: no state change
                if is_trial:
                    self._trial_in_flight = False
            raise
        with self._lock:
            if is_trial:
                self._trial_in_flight = False
            self._failures = 0
            self._opened_at = None
        return result

Complexity: O(1) time and space per call. The lock is held only for a few comparisons, never across the network call.

Tests:

class FakeClock:
    def __init__(self) -> None:
        self.now = 1000.0
    def __call__(self) -> float:
        return self.now


class Bank:
    def __init__(self) -> None:
        self.calls = 0
        self.up = True
    def charge(self, amount: int) -> str:
        self.calls += 1
        if not self.up:
            raise ConnectionError("bank timeout")
        return f"ok {amount}"


def expect(exc_type, fn, *args):
    try:
        fn(*args)
    except exc_type:
        return
    raise AssertionError(f"expected {exc_type.__name__}")


clock, bank = FakeClock(), Bank()
cb = CircuitBreaker(failure_threshold=3, reset_timeout=30, clock=clock)
assert cb.call(bank.charge, 100) == "ok 100" and cb.state == "closed"

# consecutive failures, not total: a success in between resets the count
bank.up = False
expect(ConnectionError, cb.call, bank.charge, 1)
expect(ConnectionError, cb.call, bank.charge, 1)
bank.up = True
cb.call(bank.charge, 1)
bank.up = False
expect(ConnectionError, cb.call, bank.charge, 1)
expect(ConnectionError, cb.call, bank.charge, 1)
assert cb.state == "closed"
expect(ConnectionError, cb.call, bank.charge, 1)     # third in a row
assert cb.state == "open"

# open: fail fast without touching the bank
calls = bank.calls
expect(CircuitOpenError, cb.call, bank.charge, 1)
clock.now += 29.9
expect(CircuitOpenError, cb.call, bank.charge, 1)
assert bank.calls == calls

# half-open probe fails -> open again with a FRESH timer
clock.now += 0.1
assert cb.state == "half_open"
expect(ConnectionError, cb.call, bank.charge, 1)
assert bank.calls == calls + 1 and cb.state == "open"
clock.now += 29
expect(CircuitOpenError, cb.call, bank.charge, 1)

# half-open probe succeeds -> closed, counter reset
clock.now += 1
bank.up = True
assert cb.call(bank.charge, 5) == "ok 5" and cb.state == "closed"
bank.up = False
expect(ConnectionError, cb.call, bank.charge, 1)
expect(ConnectionError, cb.call, bank.charge, 1)
assert cb.state == "closed"

# only ONE trial call while half-open (a nested call during the probe is rejected)
cb2 = CircuitBreaker(failure_threshold=1, reset_timeout=10, clock=clock)
expect(ConnectionError, cb2.call, bank.charge, 1)
clock.now += 10
seen = []
def probe():
    try:
        cb2.call(lambda: "second")
    except CircuitOpenError:
        seen.append("rejected")
    return "probe ok"
assert cb2.call(probe) == "probe ok" and seen == ["rejected"] and cb2.state == "closed"

# errors outside trip_on (bad input) propagate but never trip the breaker
cb3 = CircuitBreaker(failure_threshold=1, reset_timeout=10, trip_on=(ConnectionError,), clock=clock)
def bad_input():
    raise ValueError("negative amount")
expect(ValueError, cb3.call, bad_input)
assert cb3.state == "closed"
print("all tests passed")

A common wrong solution (the tests catch it):

import time
from typing import Any, Callable


class CircuitOpenError(RuntimeError):
    pass


class CircuitBreaker:
    # Looks right, but: counts TOTAL failures (a success never resets them),
    # and in half-open lets every caller through instead of one probe.
    def __init__(self, failure_threshold: int, reset_timeout: float, *,
                 trip_on: tuple[type[BaseException], ...] = (Exception,),
                 clock: Callable[[], float] = time.monotonic) -> None:
        self._threshold, self._reset_timeout = failure_threshold, reset_timeout
        self._trip_on, self._clock = trip_on, clock
        self._failures = 0
        self._opened_at: float | None = None

    @property
    def state(self) -> str:
        if self._opened_at is None:
            return "closed"
        if self._clock() - self._opened_at >= self._reset_timeout:
            return "half_open"
        return "open"

    def call(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
        if self.state == "open":
            raise CircuitOpenError("circuit open; failing fast")
        try:
            result = fn(*args, **kwargs)
        except self._trip_on:
            self._failures += 1
            if self._failures >= self._threshold:
                self._opened_at = self._clock()
            raise
        self._opened_at = None
        return result

Alternatives and tradeoffs:

  • Failure-rate window instead of consecutive count: open when, say, more than 50% of the last 20 calls (or the last 10 seconds) failed. More robust for high-traffic dependencies with occasional blips; needs a ring buffer or time-bucketed counters. Libraries such as pybreaker or purgatory, and service-mesh outlier detection (Envoy, Istio), implement these.
  • Count slow calls as failures: a bank that answers in 9.9 s against a 10 s timeout is effectively down. Wrap the call with a timeout, and count timeouts (or calls above a latency threshold) as tripping failures.
  • Async version: the same state machine with an asyncio.Lock, or no lock at all if all decisions happen between awaits on one event loop, and await fn().
  • The broken version counts total failures (a success never resets them) and lets every caller through in half-open, so after an outage the recovering bank is hit by the entire backlog at once, the thundering-herd problem the breaker exists to prevent.

Follow-ups:

  1. "You run 40 pods with one breaker each, in process. What are the consequences, and would you share state in Redis?" Each pod learns about the outage independently: up to 40 × threshold failed calls before all are open, and up to 40 probes in half-open. That's usually acceptable and much more robust than a shared breaker, which adds a network dependency on the hot path and can itself fail. Shared state makes sense only when the dependency is extremely sensitive to probes or is rate-limited per client. A middle ground is in-process breakers plus a shared kill switch (a feature flag) that operators can flip.
  2. "What should the checkout do when it gets CircuitOpenError?" Degrade, don't just return a 500: offer other payment methods (UPI instead of this bank's netbanking), queue the payment for later if the business allows it, or return a clear 503 with Retry-After. And make the breaker observable: emit a metric and a log line on every state transition, with the dependency name, and alert on 'open for more than N minutes'. A breaker that opens silently looks like a mysterious drop in conversions.
  3. "How do retries and the circuit breaker interact? Where does each go?" Retries go inside the breaker for a single logical call (or the breaker counts each attempt), and the breaker must short-circuit retries: once it's open, a retry loop shouldn't keep trying. Otherwise retries multiply load on a struggling dependency. Bound retries with a budget (for example at most 10% extra requests), use jittered backoff, and only retry idempotent operations or ones with an idempotency key, which a bank charge requires.

Scoring: Strong: Clean state machine with consecutive-failure reset, fresh timer on half-open failure, exactly one probe; lock not held during fn; trip_on filtering; injectable clock and deterministic tests; discusses observability, degradation and retry/breaker interaction. Done in about 30 minutes. Acceptable: Correct closed/open/half-open transitions, but allows multiple half-open probes or holds the lock across the call; fixes it when asked about concurrency. Red flag: Uses time.sleep in tests or reads time.time() directly so it can't be tested; or counts every exception, including bad input, as a dependency failure, without seeing the problem.

SR-M-C02 · Plan pending schema migrations safely

Medium · Coding · Senior (5–8 years) · migrations, deploy safety, checksums, ordering, failing loudly, release engineering · 25 min

Problem:

Your team's services run a small in-house migration runner at deploy time (think a stripped-down Alembic or Flyway). Migration files are named NNNN_description (four digits, then lowercase letters, digits and underscores) and the database records each applied migration in a schema_migrations table as (name, checksum).

Write plan_migrations(available, applied, *, allow_out_of_order=False) that returns the names of the migrations to run, in numeric order, or raises MigrationError when it isn't safe to run anything:

  1. A name that doesn't match NNNN_description → error.
  2. Two available migrations with the same number (two branches both added 0007_...) → error naming both.
  3. A migration recorded as applied that doesn't exist in the code → error (the database is ahead of this build, for example during an application rollback).
  4. An applied migration whose checksum no longer matches its file → error (someone edited a migration that already ran in production). Use the provided checksum(); it normalises CRLF to LF so a Windows checkout doesn't look like an edit.
  5. A pending migration numbered below the highest applied one (a branch merged late) → error, unless allow_out_of_order=True, in which case it's included in numeric order.

Calling it when everything is applied returns [], so it's safe on every deploy.

Signature: def plan_migrations(available: dict[str, str], applied: list[tuple[str, str]], *, allow_out_of_order: bool = False) -> list[str]:

Constraints: Up to a few thousand migrations. available maps name → SQL text, in arbitrary order. Error messages must name the offending migration(s), because they are read by whoever is on call during a failed deploy.

Examples:

  • plan_migrations({'0001_create_orders': ..., '0002_add_gstin': ..., '0010_index_gstin': ..., '0003_add_city': ...}, []) → ['0001_create_orders', '0002_add_gstin', '0003_add_city', '0010_index_gstin']
  • same files; applied = 0001, 0002, 0010 with matching checksums → MigrationError: out-of-order migrations ['0003_add_city'] are older than applied 0010 ...
  • same, with allow_out_of_order=True → ['0003_add_city']

Hints:

  1. Validate everything first and decide at the end; don't return a partial plan if a later check fails.
  2. Sort by the integer prefix, not by the dict's order. Would sorting the names as strings also work? When would it stop working?
  3. What should happen if the code is older than the database? Is skipping silently ever safe?

Reference solution:

import hashlib
import re


class MigrationError(Exception):
    """The database and the code disagree; a human must look before anything runs."""


_NAME = re.compile(r"^(\d{4})_[a-z0-9_]+$")


def checksum(sql: str) -> str:
    """Stable fingerprint of a migration body (line endings normalised)."""
    return hashlib.sha256(sql.replace("\r\n", "\n").encode()).hexdigest()[:12]


def plan_migrations(available: dict[str, str], applied: list[tuple[str, str]],
                    *, allow_out_of_order: bool = False) -> list[str]:
    """Return the names of migrations to run, in order, or raise MigrationError.

    available: migration name -> SQL text, e.g. {"0001_create_orders": "CREATE TABLE ..."}
    applied:   (name, checksum) rows from the schema_migrations table
    """
    by_number: dict[int, str] = {}
    for name in available:
        m = _NAME.match(name)
        if not m:
            raise MigrationError(f"bad migration name: {name!r}")
        number = int(m.group(1))
        if number in by_number:
            raise MigrationError(f"two migrations numbered {number:04d}: "
                                 f"{by_number[number]}, {name}")
        by_number[number] = name

    applied_names: set[str] = set()
    for name, recorded in applied:
        if name not in available:
            raise MigrationError(f"{name} is applied in the database but missing from the code "
                                 "(deploying an older build?)")
        if checksum(available[name]) != recorded:
            raise MigrationError(f"{name} was edited after it was applied")
        applied_names.add(name)

    highest_applied = max((int(n[:4]) for n in applied_names), default=0)
    pending = [by_number[n] for n in sorted(by_number) if by_number[n] not in applied_names]
    late = [name for name in pending if int(name[:4]) < highest_applied]
    if late and not allow_out_of_order:
        raise MigrationError(f"out-of-order migrations {late} are older than applied "
                             f"{highest_applied:04d}; rebase them or pass allow_out_of_order=True")
    return pending

Complexity: O(n log n) for sorting n migrations, plus O(total SQL size) to compute checksums. O(n) space.

Tests:

def expect_error(fragment, *args, **kwargs):
    try:
        plan_migrations(*args, **kwargs)
    except MigrationError as e:
        assert fragment in str(e), str(e)
        return
    raise AssertionError("expected MigrationError")


m = {
    "0001_create_orders": "CREATE TABLE orders (id INTEGER PRIMARY KEY);",
    "0002_add_gstin": "ALTER TABLE orders ADD COLUMN gstin TEXT;",
    "0010_index_gstin": "CREATE INDEX ix_orders_gstin ON orders (gstin);",
    "0003_add_city": "ALTER TABLE orders ADD COLUMN city TEXT;",
}
row = lambda name: (name, checksum(m[name]))

# fresh database: everything, in NUMERIC order (dict order is irrelevant)
assert plan_migrations(m, []) == ["0001_create_orders", "0002_add_gstin",
                                  "0003_add_city", "0010_index_gstin"]
# partially applied: only the rest; fully applied: nothing (safe to call on every deploy)
assert plan_migrations(m, [row("0001_create_orders"), row("0002_add_gstin")]) == \
    ["0003_add_city", "0010_index_gstin"]
assert plan_migrations(m, [row(n) for n in sorted(m)]) == []
# CRLF vs LF checkout of the same file must not look like an edit
crlf = dict(m, **{"0001_create_orders": m["0001_create_orders"].replace(";", ";\r\n")})
assert checksum(crlf["0001_create_orders"]) == checksum(m["0001_create_orders"].replace(";", ";\n"))

# an applied migration was edited
expect_error("0002_add_gstin was edited",
             dict(m, **{"0002_add_gstin": "ALTER TABLE orders ADD COLUMN gstin VARCHAR(15);"}),
             [row("0001_create_orders"), row("0002_add_gstin")])
# database is ahead of the code being deployed (rollback of the app, not the schema)
expect_error("0011_drop_city is applied",
             m, [row("0001_create_orders"), ("0011_drop_city", "abc")])
# two branches both added 0003
expect_error("two migrations numbered 0003",
             dict(m, **{"0003_add_pincode": "ALTER TABLE orders ADD COLUMN pincode TEXT;"}), [])
expect_error("bad migration name", dict(m, **{"4_fix": "SELECT 1;"}), [])
# 0003 merged late, after 0010 already ran in prod
applied = [row("0001_create_orders"), row("0002_add_gstin"), row("0010_index_gstin")]
expect_error("out-of-order", m, applied)
assert plan_migrations(m, applied, allow_out_of_order=True) == ["0003_add_city"]
print("all tests passed")

A common wrong solution (the tests catch it):

import hashlib


class MigrationError(Exception):
    pass


def checksum(sql: str) -> str:
    return hashlib.sha256(sql.replace("\r\n", "\n").encode()).hexdigest()[:12]


def plan_migrations(available: dict[str, str], applied: list[tuple[str, str]],
                    *, allow_out_of_order: bool = False) -> list[str]:
    # "Run whatever isn't in the table yet": trusts the table blindly.
    done = {name for name, _ in applied}
    return sorted(name for name in available if name not in done)

Alternatives and tradeoffs:

  • Use Alembic in a real SQLAlchemy codebase: it uses a revision graph (each migration names its down_revision) instead of numbers, which turns the 'two branches both added 0007' problem into explicit branch heads that must be merged (alembic merge). Flyway-style numbered files, as here, are simpler to read but need the duplicate and out-of-order checks.
  • Timestamps instead of sequence numbers (20261004_1130_add_gstin) make collisions between branches rare but make out-of-order application common, so allow_out_of_order effectively becomes the default and migrations must be written to be independent of each other.
  • The broken version just runs 'whatever isn't in the table yet', sorted by name: it silently runs a late-merged migration after a newer one, ignores edited migrations, and happily deploys code that's older than the database.

Follow-ups:

  1. "The planner says go. Twenty pods start at once during a rolling deploy. What stops them all running migrations concurrently?" Take a database-level lock around plan-and-apply: Postgres pg_advisory_lock(<constant>) or LOCK TABLE schema_migrations IN EXCLUSIVE MODE in a transaction, then re-plan after acquiring it (another pod may have just finished). Better still, run migrations as a separate one-off step in the pipeline (a Kubernetes Job or a pre-deploy hook) instead of at application start-up, so a slow migration doesn't block pods from becoming ready.
  2. "Should each migration run in a transaction? What about CREATE INDEX CONCURRENTLY?" On Postgres, yes by default: DDL is transactional, so a failed migration rolls back cleanly and the schema_migrations row is inserted in the same transaction. CREATE INDEX CONCURRENTLY can't run inside a transaction, so the runner needs a per-migration opt-out, and such migrations must be written to be re-runnable (IF NOT EXISTS, and drop an INVALID index left by a failed attempt). On MySQL, DDL causes implicit commits, so every migration must be idempotent or small enough to repair by hand.
  3. "Someone edited an applied migration to fix a typo in a comment, and the deploy is blocked by the checksum error. What do you do?" Don't 'fix' the checksum in the table as a habit, but as a reviewed one-off it's acceptable if the SQL semantics didn't change: revert the edit (preferred: applied migrations are immutable history), or run a documented repair command that updates the stored checksum with an audit trail. Then add a CI check that fails any PR modifying an existing migration file.

Scoring: Strong: All five rules, validates fully before returning, numeric ordering, clear error messages naming the migrations; reasons about rollbacks (DB ahead of code), concurrency of runners and transactional DDL. Done in about 25 minutes with tests for each rule. Acceptable: Correct ordering and pending detection with checksum validation, but misses the duplicate-number or out-of-order rule until prompted; adds them quickly. Red flag: Returns set(available) - set(applied) with no ordering or validation; or proposes silently skipping unknown or edited migrations to 'keep deploys green'.

SR-M-C03 · Consistent hashing ring for a sharded cache

Medium · Coding · Senior (5–8 years) · scaling, caching, sharding, consistent hashing, bisect, hashlib · 30 min

Problem:

Your cart service shards its cache across several Redis nodes, choosing a node with nodes[hash(key) % len(nodes)]. During the Big Billion Days sale you added a fourth node, about 75% of keys moved, the cache hit rate collapsed, and the database fell over.

Implement a consistent hashing ring with virtual nodes:

  • HashRing(nodes, vnodes=100): each node is placed on the ring at vnodes pseudo-random positions.
  • get(key) returns the node owning the first position clockwise from the key's hash (wrapping around past the end). An empty ring raises LookupError.
  • add(node) is idempotent. remove(node) raises KeyError for an unknown node.
  • The mapping must be identical in every process and on every machine, whatever order the nodes were given in.
  • Adding a node may move keys only to the new node. Removing a node may move only its own keys.

Signature: class HashRing: def __init__(self, nodes: Iterable[str] = (), vnodes: int = 100) -> None def add(self, node: str) -> None def remove(self, node: str) -> None def get(self, key: str) -> str @property def nodes(self) -> set[str]

Constraints: Up to ~50 nodes and millions of lookups per second across the fleet, so get must be O(log n), not a scan. add/remove are rare and may be O(n). Standard library only.

Examples:

  • ring = HashRing(['cache-a', 'cache-b', 'cache-c']); ring.get('cart:42') → one of the three nodes, the same one on every pod and after every restart
  • ring.add('cache-d') with 6,000 keys → about a quarter of the keys move, all of them to cache-d
  • HashRing().get('x') → LookupError

Hints:

  1. Why can't you use the built-in hash() for strings across processes?
  2. Keep a sorted list of ring positions. Which bisect function finds 'the first position clockwise'?
  3. What happens at the very end of the ring?
  4. Why place each node at many positions instead of one? Try vnodes=1 with three nodes and count keys per node.

Reference solution:

import bisect
import hashlib
from collections.abc import Iterable


def _h(text: str) -> int:
    # Stable across processes and machines, unlike hash() with PYTHONHASHSEED randomisation
    return int.from_bytes(hashlib.md5(text.encode()).digest()[:8], "big")


class HashRing:
    """Consistent hashing with virtual nodes, for sharding cache keys across servers."""

    def __init__(self, nodes: Iterable[str] = (), vnodes: int = 100) -> None:
        if vnodes < 1:
            raise ValueError("vnodes must be >= 1")
        self._vnodes = vnodes
        self._points: list[int] = []          # sorted hash positions on the ring
        self._owner: dict[int, str] = {}      # position -> node
        self._nodes: set[str] = set()
        for node in nodes:
            self.add(node)

    @property
    def nodes(self) -> set[str]:
        return set(self._nodes)

    def add(self, node: str) -> None:
        if node in self._nodes:
            return
        for i in range(self._vnodes):
            point = _h(f"{node}#{i}")
            if point in self._owner:          # 64-bit collision: astronomically rare, but be explicit
                continue
            self._owner[point] = node
            bisect.insort(self._points, point)
        self._nodes.add(node)

    def remove(self, node: str) -> None:
        if node not in self._nodes:
            raise KeyError(node)
        self._points = [p for p in self._points if self._owner[p] != node]
        self._owner = {p: n for p, n in self._owner.items() if n != node}
        self._nodes.discard(node)

    def get(self, key: str) -> str:
        if not self._points:
            raise LookupError("ring is empty")
        i = bisect.bisect_right(self._points, _h(key))
        return self._owner[self._points[i % len(self._points)]]   # wrap around

Complexity: get: O(log(N·V)) with a binary search over N nodes × V virtual nodes. add: O(V log(N·V)) hashing plus O(N·V) per insort in the worst case; remove: O(N·V). Space O(N·V).

Tests:

from collections import Counter

keys = [f"cart:{i}" for i in range(6000)]
ring = HashRing(["cache-a", "cache-b", "cache-c"], vnodes=100)
before = {k: ring.get(k) for k in keys}

# deterministic: a fresh ring (another pod) maps every key the same way
assert before == {k: HashRing(["cache-c", "cache-a", "cache-b"]).get(k) for k in keys}

# reasonably balanced with virtual nodes
load = Counter(before.values())
assert set(load) == {"cache-a", "cache-b", "cache-c"}
assert all(1400 < n < 2600 for n in load.values()), load

# adding a node only moves keys TO the new node, roughly 1/4 of them
ring.add("cache-d")
after = {k: ring.get(k) for k in keys}
moved = [k for k in keys if before[k] != after[k]]
assert all(after[k] == "cache-d" for k in moved)
assert 900 < len(moved) < 2100, len(moved)

# removing a node only moves the keys it owned, and they go back where they were
ring.remove("cache-d")
assert {k: ring.get(k) for k in keys} == before
ring.remove("cache-b")
for k in keys:
    if before[k] != "cache-b":
        assert ring.get(k) == before[k]
    else:
        assert ring.get(k) in {"cache-a", "cache-c"}

ring.add("cache-a")                 # idempotent
assert ring.nodes == {"cache-a", "cache-c"}
try:
    HashRing().get("x")
    raise AssertionError("expected LookupError")
except LookupError:
    pass
try:
    ring.remove("cache-z")
    raise AssertionError("expected KeyError")
except KeyError:
    pass
print("all tests passed")

A common wrong solution (the tests catch it):

import hashlib
from collections.abc import Iterable


def _h(text: str) -> int:
    return int.from_bytes(hashlib.md5(text.encode()).digest()[:8], "big")


class HashRing:
    # Modulo sharding dressed up as a ring: stable and balanced, but adding or
    # removing a node remaps most keys.
    def __init__(self, nodes: Iterable[str] = (), vnodes: int = 100) -> None:
        self._nodes: list[str] = sorted(set(nodes))

    @property
    def nodes(self) -> set[str]:
        return set(self._nodes)

    def add(self, node: str) -> None:
        if node not in self._nodes:
            self._nodes = sorted(self._nodes + [node])

    def remove(self, node: str) -> None:
        self._nodes.remove(node)

    def get(self, key: str) -> str:
        if not self._nodes:
            raise LookupError("ring is empty")
        return self._nodes[_h(key) % len(self._nodes)]

Alternatives and tradeoffs:

  • Virtual-node count: more vnodes give better balance at the cost of memory and add/remove time. With 6,000 keys in this exercise, vnodes=100 gave 2,090 / 2,056 / 1,854 keys per node, while vnodes=1 gave 2,782 / 1,844 / 1,374. Weight bigger nodes by giving them more vnodes.
  • Rendezvous (highest-random-weight) hashing: for each key, score every node with hash(node, key) and pick the highest. It has no ring and perfect minimal movement, at O(N) per lookup, which is fine for tens of nodes.
  • Jump consistent hash (Lamping & Veach): O(log N), no memory, perfectly balanced, but nodes must be numbered 0..N-1 and can only be added or removed at the end, so it suits shard numbers rather than named servers.
  • Redis Cluster uses fixed hash slots (16,384, CRC16(key) % 16384) assigned to nodes, so rebalancing moves slots explicitly rather than relying on hashing. If you can use the cluster's own sharding, do.
  • The broken version is modulo hashing behind the same API: balanced and stable, but adding a fourth node remaps about 75% of keys, the incident in the problem statement.

Follow-ups:

  1. "A node is removed and its keys move to its neighbours on the ring. What happens to the database during that minute, and how do you soften it?" Every moved key is a cache miss, so the database briefly sees 1/N of the traffic uncached. That's survivable only if the DB has headroom. Soften it with request coalescing (single-flight) so each hot key is loaded once, by warming the new owner before switching traffic (dual reads during migration), by adding nodes one at a time off-peak, and with a stale-while-revalidate fallback so a short miss storm doesn't become a 'hit the DB synchronously' storm.
  2. "Two app versions are deployed side by side during a rollout, and the new one uses vnodes=160. What breaks?" The two versions disagree on key placement, so the same key is written to one node and read from another: miss rates go up, and worse, stale values are read from the old location after the new owner was updated. Ring parameters (hash function, vnode count, node names) are part of the data contract. Change them only with a migration plan, and keep them in shared config rather than code defaults.
  3. "Why hashlib.md5 here? Isn't MD5 broken?" MD5 is broken for security (collisions can be crafted), but here we only need a fast, well-distributed, stable hash, and an attacker choosing keys to unbalance shards is usually not in the threat model. If it is (user-controlled keys on a public API), use a keyed hash such as hashlib.blake2b(key, key=secret) so placement can't be predicted. Faster non-cryptographic options (xxhash, murmur) need third-party packages.

Scoring: Strong: Sorted positions + bisect_right + wrap-around, hashlib-based stable hashing, vnodes, idempotent add, correct remove; tests that prove minimal movement, not just 'returns a node'; discusses miss storms on rebalance and ring config as a contract. About 30 minutes. Acceptable: Working ring with stable hashing but no virtual nodes, or an O(n) scan in get; adds them when asked about balance and lookup cost. Red flag: Uses built-in hash() (different per process because of hash randomisation) or modulo hashing and can't explain why adding a node moves most keys.

SR-M-D01 · Real-time fraud scoring in the payment path

Medium · Design · Senior (5–8 years) · model serving, FastAPI, latency budgets, GIL and worker processes, feature stores, model versioning, fallbacks, observability · 40 min

Scenario:

A payments company authorises card and UPI transactions. Before authorisation, the payment service must call a fraud-scoring service and get a score within 60 ms at p99 (the whole authorisation has a 300 ms budget). Traffic is ~1,500 transactions/s normally and ~6,000/s at peaks (salary day, festive sales).

The data science team has trained a gradient-boosted tree model (LightGBM, ~300 MB in memory, ~2 ms CPU per prediction) in a notebook. It uses ~80 features: some come with the request (amount, merchant category, device ID), and some are aggregates such as "number of transactions on this card in the last 10 minutes" and "average ticket size for this merchant over 30 days". They retrain weekly and want to ship new models without engineering involvement.

Design the scoring service in Python.

Clarifying questions:

  • What happens to a payment if scoring is slow or down: approve, decline, or fall back to rules? (Fall back to a rules engine; never block payments on the model.)
  • Is the decision made by the fraud service (approve/decline/step-up to OTP) or just a score that the payment service thresholds?
  • How fresh must the velocity features be? (Last-10-minute counts must include transactions up to a few seconds ago.)
  • Where do the batch aggregates come from today, and how were the training features computed (same code or a separate SQL)?
  • What are the audit requirements? (RBI and the card networks expect you to explain a decline; keep inputs, model version and score per decision.)
  • How often may thresholds change, and who owns them?

Reference design:

Request path (target p99 ≤ 60 ms):

  1. Payment service → POST /score (FastAPI) with the transaction. Hard client-side timeout of 60 ms; on timeout or error the payment service uses the rules engine and tags the decision fallback=true.
  2. Online features: one round trip to an online feature store (Redis Cluster or similar) using an async client with a 15 ms timeout, an MGET/pipeline of all keys (card, device, merchant). Missing features take the defaults the model was trained with; if the store times out, score with request-only features using a degraded model, or fall back.
  3. Inference: LightGBM predict is CPU-bound (~2 ms) and holds the GIL for most of that time, so a single process can't use more than one core. Run N Uvicorn/Gunicorn worker processes per pod (≈ vCPUs) and call predict directly in the request (2 ms is acceptable on the loop), or in a dedicated thread pool if the library releases the GIL (LightGBM's C++ predict does, so threads can help; measure). At 6,000/s × 2 ms ≈ 12 cores of pure inference, so ~24 cores with headroom across pods.
  4. Response: {score, model_version, reason_codes, decision}; write the decision record asynchronously (to Kafka, not inline to a DB) for audit and training labels.

Model loading and versioning: models are immutable artifacts in a registry (MLflow or an S3 path plus a manifest with version, feature list, training data window and validation metrics). Each pod loads the model at startup (FastAPI lifespan) and before taking traffic, checks that the model's expected feature list matches the feature schema the service builds, then runs warm-up predictions; readiness passes only after that. Load in the master process before forking Gunicorn workers so the 300 MB is shared copy-on-write (Python reference counting touches objects, so verify actual RSS, or accept N × 300 MB). Rolling out a model = changing the manifest version and doing a rolling restart, not hot-swapping a global inside running workers.

Safe rollout: shadow mode first (new model scores every request in parallel, decisions still from the old one; compare score distributions and decline rates offline), then a canary by hashing the card ID (stable assignment), then full. Automated guardrails: block promotion if the decline rate at the configured threshold moves by more than X% on shadow traffic.

Feature consistency (training/serving skew): velocity features are computed by a streaming job (Kafka → Flink, or a Python consumer with Redis INCR plus TTL buckets per minute) and written to the online store, and the same definitions are used to produce point-in-time-correct training data. A daily job compares the online features logged at decision time against offline recomputation and alerts on drift.

Observability: per-stage latency (feature fetch, inference, total) as histograms, fallback rate, feature-missing rate per feature, score distribution per model version, decline rate, and model version in every log line and trace span.

Trade-offs:

  • Python service vs a dedicated model server (Triton, a compiled tree runtime such as Treelite, or ONNX Runtime): Python with FastAPI is easiest for the team and fine at ~2 ms per prediction; a compiled runtime cuts CPU cost by several times if inference dominates the bill. Start in Python, keep the model artifact portable.
  • Processes vs threads: processes give real parallelism regardless of the GIL but multiply memory; threads share the model but only help if prediction releases the GIL. Measure before choosing.
  • Per-request vs micro-batching: batching improves throughput but adds queueing delay, which eats the 60 ms budget. Tree models are cheap per row, so per-request is usually right here.
  • Fail open (rules fallback) vs fail closed: failing open keeps payments flowing during an outage, at the cost of fraud exposure; make fallback visible (metric, alert) and time-boxed.
  • Weekly retrain with no engineering involvement vs safety: self-service needs automated gates (schema check, offline metrics, shadow comparison), or it becomes self-service production incidents.

Deep-dive probes:

  • "p99 is 45 ms most of the day but jumps to 400 ms for a few seconds every minute." Look for: GC pauses or a periodic task on the event loop, Redis latency spikes, CPU throttling from Kubernetes CPU limits (CFS quota), and a blocking call in an async def endpoint; how to tell them apart (per-stage histograms, py-spy dump during a spike, container throttling metrics).
  • "The new model declines 3× more transactions in production than in offline evaluation." Look for: training/serving skew (a feature computed differently online, a missing feature defaulting to 0 instead of the training-time default, a unit change such as rupees vs paise), checking the feature schema at load time, and shadow-mode comparison before promotion.
  • "A regulator asks why transaction X was declined three months ago." Look for: immutable decision records with the input features, model version and threshold version; models kept in the registry for the retention period; reason codes (top SHAP contributions computed offline or at decision time if the budget allows).
  • "Traffic doubles for Diwali." Look for: load testing at 2× with realistic feature-store load, horizontal pod autoscaling on CPU plus pre-scaling before the event (autoscalers react too slowly to a sale that starts at midnight), and capacity on the feature store, which usually saturates before the scorers do.

Rubric: Strong: Explicit latency budget per stage with timeouts and a defined fallback; reasons about the GIL and CPU-bound inference (worker processes, threads only if predict releases the GIL, memory cost of N copies); startup loading with readiness and schema checks; versioned artifacts with shadow and canary rollout; addresses training/serving skew and audit records; concrete metrics. Quantifies capacity. Acceptable: Reasonable FastAPI service with model loaded at startup, Redis features and a fallback, but vague on processes vs threads or on rollout safety; gets there with probing. Red flag: Loads the model per request or pickles it from S3 on demand, runs CPU-bound inference in an async def endpoint and expects asyncio to parallelise it, has no fallback when scoring is slow, or ships new models by overwriting a file in place.

SR-M-D02 · An internal Python SDK used by 40 teams

Medium · Design · Senior (5–8 years) · library design, packaging, semantic versioning, dependency management, typing, sync and async APIs, deprecation, testing · 40 min

Scenario:

Your platform team owns the internal payments API (REST, OAuth2 client credentials). Today ~40 teams call it with hand-written requests code: each team has its own retry logic (some retry non-idempotent POST /charges, causing double charges), half of them log full card tokens, and a breaking change last quarter took four weeks of chasing teams to roll out.

You're asked to build and own an official Python SDK, acme-payments, that every team will use. Consumers range from Django monoliths on Python 3.10 to FastAPI services on 3.12, Airflow DAGs, and notebooks. Some teams are still on Pydantic v1.

Design the SDK: its API, packaging, versioning, and how you'll evolve it.

Clarifying questions:

  • Which Python versions must be supported, and for how long? (3.10–3.12 now; 3.13 within the year.)
  • Do consumers need async? (The FastAPI services do; Django and Airflow are sync.)
  • Is there an internal package index (Artifactory, CodeArtifact), and how do teams pin dependencies (lock files, ranges)?
  • Does the payments API have an OpenAPI spec that is kept accurate? Could we generate from it?
  • Which operations are idempotent, and does the API accept an Idempotency-Key header?
  • What observability stack do teams use (OpenTelemetry, structured logging)?

Reference design:

API surface (small, typed, hard to misuse): - PaymentsClient (sync, built on httpx.Client) and AsyncPaymentsClient (httpx.AsyncClient) with the same method names, e.g. client.charges.create(amount_paise=..., currency="INR", idempotency_key=...). Share request building, validation and response parsing in a sans-I/O core so the two clients can't drift; only the transport differs. - Explicit types: request and response models as frozen dataclasses or TypedDicts (or Pydantic v2 models, see trade-offs), money as integer paise, enums for statuses, and a py.typed marker so mypy and pyright use the annotations. - Errors as a small hierarchy: PaymentsError → APIError(status, code, request_id) → RateLimited, NotFound, Conflict, ServerError; TransportError for network problems. Never leak httpx exceptions; consumers shouldn't depend on your transport. - Safe defaults built in: connect and read timeouts always set; retries with backoff and jitter only for idempotent methods or when an idempotency key is present (the SDK generates one per logical call and reuses it across retries); Retry-After honoured; card tokens and secrets redacted in repr and logs. - Configuration via constructor arguments with environment-variable fallbacks (ACME_PAYMENTS_BASE_URL), never global mutable state. OAuth token caching and refresh inside the client, thread-safe for the sync client and task-safe for the async one. - Observability hooks: an OpenTelemetry span per call (if opentelemetry-api is installed, optional dependency) and a structured logging logger named acme_payments with a NullHandler, so the library never configures logging for its host.

Packaging: pyproject.toml with a standard build backend (hatchling or setuptools); pure-Python wheel published to the internal index from CI on tag. Minimal, loosely bounded dependencies (httpx>=0.25,<1), because tight pins in a library cause resolver conflicts in 40 different apps. Optional extras (acme-payments[otel]). Support matrix tested in CI across Python 3.10–3.12 and the lowest and highest allowed versions of each dependency.

Versioning and evolution: semantic versioning with a written public-API policy (everything not underscore-prefixed and documented is public). Additive changes in minors; removals only in majors, after at least one minor with DeprecationWarning (with stacklevel pointing at the caller) and a changelog entry. Keep the previous major on a maintenance branch for security fixes for N months. Track adoption: the SDK sends a User-Agent: acme-payments/2.3.1 python/3.12, so the API team can see which versions are still in use before removing anything server-side.

Testing for consumers: ship a test double in the package (acme_payments.testing.FakePaymentsClient that behaves like the API, including idempotency and error cases) and httpx.MockTransport helpers, so teams don't hand-roll mocks that drift. Inside the SDK: unit tests on the sans-I/O core, contract tests against the API's staging environment in CI, and type-checking the examples.

Trade-offs:

  • Generated (from OpenAPI) vs hand-written: generation keeps endpoints in sync and scales with API size, but generated code is often awkward (huge flat namespaces, weak errors). A hybrid is common: generate the models and low-level calls, and hand-write a thin ergonomic layer with retries, pagination helpers and errors.
  • Pydantic models vs dataclasses for responses: Pydantic gives validation and serialisation for free, but forcing Pydantic v2 on teams still on v1 blocks their adoption. Dataclasses or TypedDicts with a small hand-written parser have zero dependency cost. A library should be stingy with heavy dependencies.
  • Sync and async in one package vs two: one package with a shared core avoids duplicated logic; two packages give smaller dependency footprints. Calling async from sync via asyncio.run inside the library is a trap (it fails inside running loops, e.g. Jupyter).
  • Retries in the SDK vs in callers: centralising them fixes the double-charge problem everywhere, but defaults must be conservative and overridable, because the SDK doesn't know each caller's latency budget.
  • Monorepo vendoring vs index releases: if all 40 teams are in one monorepo, a single version moving in lockstep removes version skew entirely; with polyrepos you need semver discipline and adoption tracking.

Deep-dive probes:

  • "Team X can't install 2.0 because it requires httpx>=0.27 and their other dependency pins httpx<0.26." Look for: why libraries use wide ranges, testing against the lower bound in CI, avoiding unnecessary minimum bumps, and vendoring or replacing a dependency only as a last resort.
  • "A consumer reports duplicate charges after upgrading." Look for: how idempotency keys are generated (once per logical call, reused across retries, exposed so callers can pass their own business key), which status codes trigger retries, a regression test with a transport that drops the connection after the server commits, and an incident process (yank the release from the index, publish a patch).
  • "How do you know it's safe to remove the deprecated charge() function?" Look for: User-Agent or telemetry on deprecated calls, a code search across repos, warnings escalated to errors in consumers' CI, a published removal date, and a major-version bump.
  • "The async client sometimes raises 'attached to a different loop' in a FastAPI app." Look for: client lifecycle tied to the app lifespan (create on startup, close on shutdown), never creating an AsyncClient at import time or sharing one across event loops, and documenting that ownership.

Rubric: Strong: Small typed API with sync and async parity over a shared core; safe defaults for timeouts, idempotent-only retries and redaction; library hygiene (NullHandler logging, no global config, loose dependency ranges, optional extras, py.typed); explicit semver and deprecation policy with adoption telemetry; ships a fake for consumers' tests; discusses generated vs hand-written and dependency-conflict trade-offs. Acceptable: Sensible client class with retries, typed models and a versioning story, but pins dependencies tightly, forces Pydantic v2, or has no plan for consumer testing or deprecation; improves when probed. Red flag: Wraps requests with module-level global configuration, retries every method including non-idempotent POSTs, configures the root logger from the library, or has no versioning or compatibility policy for 40 consumers.

Medium & Tricky (MT)

SR-MT-O01 · A retried load that reports success and loses rows

Medium & Tricky · Output · Senior (5–8 years) · retries, generators, iterator exhaustion, idempotency, data pipelines, decorators · 10 min

Question: A loader stages rows and writes them to the warehouse in one go. The first attempt hits a connection reset after two rows, so the retry decorator calls it again. It's run once with a list and once with a generator. What does this print?

import functools

def retry(times):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return fn(*args, **kwargs)
                except ConnectionError as e:
                    print(f"  attempt {attempt} failed: {e}")
            raise RuntimeError("gave up")
        return wrapper
    return deco

warehouse = []
flaky = iter([True, False])               # first bulk insert drops the connection

@retry(times=3)
def load_batch(rows):
    staged = []
    for row in rows:
        staged.append(row)
        if len(staged) == 2 and next(flaky, False):
            raise ConnectionError(f"reset after {len(staged)} rows")
    warehouse.extend(staged)
    return len(staged)

def parse(lines):
    for line in lines:
        order_id, amount = line.split(",")
        yield order_id, int(amount)

lines = ["A1,100", "A2,250", "A3,75", "A4,30"]
print("list:", load_batch(list(parse(lines))), warehouse)
warehouse.clear(); flaky = iter([True, False])
print("gen: ", load_batch(parse(lines)), warehouse)

Expected output:

  attempt 1 failed: reset after 2 rows
list: 4 [('A1', 100), ('A2', 250), ('A3', 75), ('A4', 30)]
  attempt 1 failed: reset after 2 rows
gen:  2 [('A3', 75), ('A4', 30)]

The trap: gen: 4 with all four rows, the same as the list run. The decorator looks like it "calls the function again from scratch", and the generator looks like "the data". Tempting because retry decorators are usually tested with lists or scalar arguments, where re-calling really is a fresh start.

Explanation:

Two mechanisms combine.

1. A retry re-invokes the function with the same argument objects. wrapper holds on to args and calls fn(*args) again. It doesn't (and can't) recreate the inputs. For a list that is harmless: a for loop over a list creates a fresh iterator each time, so attempt 2 starts at A1.

2. A generator is a one-shot iterator with its position stored inside it. The first attempt pulled A1 and A2 from parse(lines) before the reset. Attempt 2 iterates the same generator object, which continues from where it stopped: A3, A4. staged is a fresh local list each attempt, so the first two rows were thrown away with the failed attempt's staged.

Why this is nasty in production: nothing raises. The retry "succeeded", the function returned a count (2), the job is green, and two rows are silently missing. Any row count you log comes from the same consumed iterator, so it agrees with the wrong result. Only reconciliation against the source (expected 4, loaded 2) catches it.

(The print inside the retry appears before list: / gen: because the arguments of the outer print are evaluated first, which includes running load_batch.)

Takeaway: retry re-playable inputs only. Materialise one batch (a list, or a re-openable source such as a file path plus an offset) before the retry boundary, or put the retry inside, around the single network call.

Follow-ups:

  1. "How would you make the retry decorator itself defend against this?" It can refuse one-shot iterators: before the first attempt, check the arguments with isinstance(arg, collections.abc.Iterator) (generators, map, file objects) and raise a TypeError that says to pass a sequence. That is cheap and catches the bug at the first call in tests. Silently calling list() on the iterator is worse, because it hides a memory cost the caller didn't choose.
  2. "Where should the retry boundary sit in a pipeline that streams 50 million rows from a file into a warehouse?" Around the smallest unit you can re-run cheaply and idempotently, usually one batch (say 10,000 rows) that has been materialised in memory, with the network write as the retried operation. The batch is identified by (file, offset range), so a retry, or a restart after a crash, resends exactly the same rows. The warehouse write must be idempotent per batch (a MERGE on keys, or a staging table swapped in atomically), because the 'connection reset' may happen after the server committed.
  3. "What monitoring would have caught the generator version quickly?" Per-batch reconciliation: rows read from the source vs rows acknowledged by the sink, emitted as metrics and compared at the end of the job, failing the job on a mismatch. Also count retries per job; a retry rate going from 0 to non-zero is worth an alert in its own right.

Scoring: Strong: Exact output, including the print order; names both mechanisms (the retry reuses argument objects, and the generator resumes from its internal position, losing the rows staged in the failed attempt); points out the silent success; proposes a re-playable batch at the retry boundary, an idempotent sink and reconciliation. Acceptable: Realises the generator run differs and that rows are lost, but gets the count or the missing rows wrong at first (e.g. says it raises StopIteration or loads 0 rows); corrects after tracing the generator's position. Red flag: Predicts identical output for both runs and, after seeing it, blames the decorator rather than the iterator; or proposes 'retry with more attempts'.

SR-MT-O02 · Stored exceptions that keep a large batch alive

Medium & Tricky · Output · Senior (5–8 years) · memory, tracebacks, frames, reference cycles, garbage collection, weakref · 10 min

Question: A worker keeps recent exceptions in a list for its /health page. Memory grows by about 400 MB per failed batch until someone clears the list, and sometimes even after that. The Batch class stands in for a large DataFrame, and a weakref shows whether it is still alive. Automatic GC is disabled so that collection happens only where the snippet asks for it. What does this print? (CPython)

import gc
import weakref

gc.disable()          # make collection timing explicit for the demo


class Batch:          # stands in for a 400 MB DataFrame
    pass


recent_errors = []    # shown on the /health page


def process(batch_id, keep_alias):
    batch = Batch()
    probe = weakref.ref(batch)
    try:
        raise ValueError(f"bad GSTIN in batch {batch_id}")
    except ValueError as e:
        recent_errors.append(e)
        if keep_alias:
            last_error = e
    return probe


p1 = process(1, keep_alias=False)
p2 = process(2, keep_alias=True)
print("after return:", p1() is not None, p2() is not None)

recent_errors.clear()
print("after clear: ", p1() is not None, p2() is not None)

gc.collect()
print("after gc:    ", p1() is not None, p2() is not None)

Expected output:

after return: True True
after clear:  False True
after gc:     False False

The trap: False False on the first line, on the reasoning that batch is a local variable and locals die when the function returns. Some candidates who know about tracebacks then answer False False on the second line too, expecting clear() to free both, and miss the cycle.

Explanation:

Two mechanisms combine.

1. An exception keeps its stack frames, and frames keep their locals. e.__traceback__ references the frame of process, and that frame references every local, including batch. Storing e in recent_errors therefore keeps each batch alive after process returns: True True. The /health list is effectively a list of DataFrames.

2. Whether the memory comes back on clear() depends on whether there's a reference cycle.

  • Batch 1: Python deletes the except ... as e name at the end of the except block (an implicit del e), precisely to avoid a cycle. The only path to the frame is recent_errors → exception → traceback → frame. clear() drops the last reference, CPython's reference counting frees the chain at once, and p1() is dead.
  • Batch 2: last_error = e creates a second local that is not auto-deleted. Now the frame references the exception (through last_error) and the exception references the frame (through its traceback): a cycle. Reference counts never reach zero, so only the cyclic garbage collector can free it. With GC disabled it survives clear(); gc.collect() frees it.

In a real service GC is enabled, but generation-2 collections are infrequent, so cycles holding hundreds of MB can sit there for a long time. Under memory pressure that is an OOM kill rather than a slow leak.

Takeaway: never keep exception objects longer than the request. Store traceback.format_exception(e) strings or a small error record, and avoid binding the exception to another local in the frame that raised it.

Follow-ups:

  1. "How would you have found this in production without reading the code?" Watch RSS per worker over time (a sawtooth that only drops at deploys or restarts), then take snapshots: tracemalloc (compare two snapshots grouped by traceback), or gc.get_referrers / objgraph.show_backrefs on a large object to see the chain list → ValueError → traceback → frame → DataFrame. memray attached to a canary is the heavier-duty option. The tell-tale sign is frame or traceback objects on the path to your data.
  2. "You need the stack trace on the health page. How do you keep it without keeping the frames?" Format it immediately: ''.join(traceback.format_exception(e)) and store the string with the batch ID and time in a bounded deque(maxlen=50). Alternatives are traceback.TracebackException(...), which captures a lightweight summary (without locals unless capture_locals=True), or traceback.clear_frames(e.__traceback__) to drop the locals while keeping the exception. Setting e.__traceback__ = None loses the trace entirely.
  3. "Same bug, but in an asyncio service: a task fails and its exception is stored by task.exception() in a list. Anything different?" Same mechanism: the exception's traceback references the coroutine's frames and therefore its locals. Also note that a failed Task object itself holds its exception, so keeping a list of finished tasks (for example in a 'background jobs' registry) leaks in the same way; remove tasks in a done-callback and store a summary instead.

Scoring: Strong: Exact output; explains traceback → frame → locals, the implicit del e at the end of the except block, and the cycle created by last_error; knows that refcounting frees acyclic garbage immediately while cycles wait for the GC; proposes formatting tracebacks into a bounded structure and names tools (tracemalloc, gc.get_referrers, memray) to find it. Acceptable: Knows that stored exceptions keep locals alive (first line right) but expects both to be freed on clear(); understands the cycle once shown that last_error lives in the same frame. Red flag: Says locals are always freed on return and can't see how the list holds batch; or 'fixes' the leak by calling gc.collect() after every batch.

SR-MT-O03 · A library's deprecation warning that its users never see

Medium & Tricky · Output · Senior (5–8 years) · library design, deprecation, warnings, stacklevel, warning filters, API evolution · 10 min

Question: You maintain paysdk, an internal payments library used by many teams. You're deprecating pay() in favour of charge(). The snippet builds two small modules in memory (paysdk, and a consumer module billing) and runs with Python's default warning filters. showwarning is replaced only so that warnings print to stdout. What does it print?

import sys
import types
import warnings


def show(message, category, filename, lineno, *rest):
    where = filename if filename in ("paysdk.py", "billing.py") else "main script"
    print(f"  {category.__name__} ({where}, line {lineno}): {message}")


warnings.showwarning = show       # print to stdout so the demo is easy to read


def make_module(name, source):
    mod = types.ModuleType(name)
    exec(compile(source, f"{name}.py", "exec"), mod.__dict__)
    sys.modules[name] = mod
    return mod


paysdk = make_module("paysdk", '''
import warnings

def charge(amount, *, currency="INR"):
    return f"charged {amount} {currency}"

def pay(amount):
    warnings.warn("pay() is deprecated; use charge()", DeprecationWarning)
    return charge(amount)

def pay_v2(amount):
    warnings.warn("pay_v2() is deprecated; use charge()", DeprecationWarning, stacklevel=2)
    return charge(amount)
''')

billing = make_module("billing", '''
import paysdk

def monthly_invoice():
    return paysdk.pay_v2(999)
''')

print("1", paysdk.pay(100))
print("2", paysdk.pay_v2(100))
for _ in range(2):
    print("3", paysdk.pay_v2(200))
print("4", billing.monthly_invoice())

Expected output:

1 charged 100 INR
  DeprecationWarning (main script, line 44): pay_v2() is deprecated; use charge()
2 charged 100 INR
  DeprecationWarning (main script, line 46): pay_v2() is deprecated; use charge()
3 charged 200 INR
3 charged 200 INR
4 charged 999 INR

The trap: A warning on every one of the five calls (or at least on 1, 2 and 4), on the belief that warnings.warn(..., DeprecationWarning) always prints something. This is what most library authors assume when they add the warning, and why many deprecations reach the removal release with nobody having seen them.

Explanation:

Two mechanisms decide whether a warning is shown.

1. stacklevel decides which module the warning is attributed to. A warning is attributed to the module and line of the frame stacklevel levels up the stack. With the default stacklevel=1, pay()'s warning belongs to paysdk.py, the line that called warnings.warn. With stacklevel=2, pay_v2()'s warning belongs to the caller: the main script for calls 2 and 3, and billing.py for call 4.

2. The default filters show DeprecationWarning only when it is attributed to __main__. Since Python 3.7 the default filter list includes default::DeprecationWarning:__main__ followed by ignore::DeprecationWarning. So:

  • Call 1 is attributed to paysdk, so it is ignored. With the default stacklevel, a library's own deprecation warning is never shown to anyone by default.
  • Call 2 is attributed to the main script, so it is shown, pointing at the user's line (44), which is the line they need to change.
  • Call 3 is shown once, not twice. The default action prints the first occurrence per location (module + line), and both loop iterations come from line 46.
  • Call 4 is attributed to billing, which is not __main__, so it is ignored. Most real usage of a library comes from other modules, not from the entry script, so even with stacklevel=2 most callers see nothing in production.

This matches the intent of the defaults: DeprecationWarning is aimed at developers, who are expected to see it in their test runs (pytest shows them), not at end users.

Takeaway: pass stacklevel so the warning points at the caller, and don't rely on default filters for visibility. Make your consumers' CI run with -W error::DeprecationWarning (or pytest filterwarnings = error), or use FutureWarning when the audience is end users.

Follow-ups:

  1. "You have 40 consuming teams and want to remove pay() in the next major version. What's your deprecation process?" Warn with stacklevel=2 and a message naming the replacement and the removal version; document it in the changelog; tell consumers to turn paysdk deprecations into errors in their test config (filterwarnings = error::DeprecationWarning:paysdk doesn't work because the module field matches the caller, so filter on the message or a custom PaysdkDeprecationWarning subclass); measure real usage, by emitting a metric or log on each deprecated call or by searching the monorepo or dependency graph, so you know who still calls it; then remove in a major version under semver, with the old API as a no-op shim only if the usage data says you need it.
  2. "Why might a custom warning class (class PaysdkDeprecationWarning(DeprecationWarning)) be better than a bare DeprecationWarning?" Consumers can target it precisely: -W error::paysdk.PaysdkDeprecationWarning or filterwarnings with that category escalates only your deprecations without breaking on every third-party library's warnings. It also lets you add fields (removal version, replacement) and test for it with pytest.warns(PaysdkDeprecationWarning).
  3. "pay() is called through a decorator, @deprecated("use charge"), that wraps the function. What stacklevel does the wrapper need?" One more level for each wrapper frame: inside the decorator's wrapper, stacklevel=2 points at the caller of wrapper, which is correct only if warn is called directly in wrapper. If it is called from a helper inside the wrapper, use 3. Python 3.13 adds warnings.deprecated (PEP 702), which does this for you and is also understood by type checkers; on 3.12 the same decorator is available as typing_extensions.deprecated.

Scoring: Strong: Exact output, including that call 3 prints once and call 4 prints nothing; explains stacklevel attribution, the __main__-only default filter and once-per-location; proposes a deprecation process (custom category, CI escalation, usage metrics, semver removal) suited to a library with many consumers. Acceptable: Knows DeprecationWarning is hidden by default and that stacklevel=2 points at the caller, but expects call 3 to print twice or call 4 to print; corrects after the filter list is shown. Red flag: Expects every call to warn; or has never used stacklevel and suggests print() or logging as the deprecation mechanism for a shared library.

SR-MT-O04 · Thread-local request context in a thread pool

Medium & Tricky · Output · Senior (5–8 years) · threading.local, ThreadPoolExecutor, request context, security, concurrency · 8 min

Question: A legacy service keeps the current user in a threading.local() and handles requests on a thread pool. The auth middleware sets ctx.user only when the request carries a token; public endpoints have none. With one worker thread (so the output is deterministic), what does this print?

import threading
from concurrent.futures import ThreadPoolExecutor

ctx = threading.local()


def current_user():
    return getattr(ctx, "user", "anonymous")


def handle(request):
    if "user" in request:                 # auth middleware sets it only when a token is present
        ctx.user = request["user"]
    ctx.calls = getattr(ctx, "calls", 0) + 1
    return f"{request['path']} as {current_user()} (call {ctx.calls} on this thread)"


requests = [
    {"path": "/orders", "user": "priya"},
    {"path": "/health"},
    {"path": "/orders", "user": "arjun"},
    {"path": "/catalog"},
]

with ThreadPoolExecutor(max_workers=1) as pool:
    for line in pool.map(handle, requests):
        print(line)

print("main thread:", current_user(), getattr(ctx, "calls", 0))

Expected output:

/orders as priya (call 1 on this thread)
/health as priya (call 2 on this thread)
/orders as arjun (call 3 on this thread)
/catalog as arjun (call 4 on this thread)
main thread: anonymous 0

The trap: /health as anonymous and /catalog as anonymous, with every call numbered 1, on the belief that thread-local means request-local. That holds in a thread-per-request server, where each request gets a new thread, and it is how many Flask-era codebases were written before moving to a pool.

Explanation:

Two mechanisms combine.

1. threading.local is per thread, not per task or request. Each thread sees its own attribute dictionary on ctx. The main thread never set anything, so it sees anonymous and 0 calls, while the worker's values are invisible to it.

2. A pool reuses its threads. ThreadPoolExecutor(max_workers=1) runs all four requests on the same worker thread, one after another. Nothing clears the thread-local between tasks. So:

  • /health carries no token, so the middleware doesn't touch ctx.user and the request inherits priya from the previous request on that thread.
  • /catalog likewise inherits arjun.
  • ctx.calls keeps counting across requests (1–4), which shows the state is per thread, not per request.

With more workers the leak doesn't go away. It becomes non-deterministic, depending on which thread picks up which request, which makes it much harder to reproduce. In a real system this is a security incident: an anonymous request can act as, or see data of, the previous authenticated user on that thread, and audit logs attribute actions to the wrong person.

Takeaway: per-request state must be set and cleared at the request boundary (try/finally), or passed explicitly. In asyncio code use contextvars, which are scoped per task.

Follow-ups:

  1. "What's the minimal fix in this code, and what's the better fix?" Minimal: always assign at the start of every request (ctx.user = request.get("user")) and clear in a finally (del ctx.user or reset to None) in the one place requests enter, a middleware or wrapper, so no handler can forget. Better: stop using ambient state for identity. Pass the user explicitly (FastAPI's dependency injection, Depends(get_current_user)), or use a ContextVar set with a token and reset with var.reset(token) in finally. That works for threads (when the executor copies the context) and for asyncio tasks.
  2. "Would replacing threading.local with a contextvars.ContextVar alone fix it in this thread pool?" Not by itself. ThreadPoolExecutor.submit/map does not copy the caller's context; the task runs in the worker thread's own context, which persists across tasks, so a ContextVar.set in one task is still visible to the next task on that thread. You need contextvars.copy_context().run(handle, req) per task (as asyncio.to_thread does), or set/reset(token) in a finally. The discipline of resetting at the boundary matters more than the storage mechanism.
  3. "How would you write a test that catches this class of bug?" Run an authenticated request followed by an unauthenticated one through the same worker (a single-thread executor, or the server with one worker), and assert the second sees no user. Make it a standard regression test for the middleware. Also add a check in the request wrapper that the context is empty on entry and log or alert if not, which catches leaks introduced later by code that sets context in unexpected places.

Scoring: Strong: Exact output; explains per-thread storage plus pool thread reuse and the conditional assignment; calls out the security impact and non-determinism with more workers; gives the set-and-reset-at-the-boundary fix, prefers explicit passing or ContextVars, and knows executors don't copy context by default. Acceptable: Realises the user leaks to /health but expects calls to reset per request, or misses that the main thread sees nothing; reaches the try/finally fix with a prompt. Red flag: Treats thread-local as request-local, or proposes adding a lock (which doesn't address the leak at all).

SR-MT-C01 · Async micro-batcher for a model-serving endpoint

Medium & Tricky · Coding · Senior (5–8 years) · model serving, asyncio, batching, futures, cancellation, error propagation, latency vs throughput · 35 min

Problem:

A FastAPI service scores fraud risk one transaction per request, but the model server (GPU) is much cheaper per row when called with a batch. Write an asyncio micro-batcher that sits between the endpoint and the model:

  • await batcher.predict(x) returns the model's output for x.
  • Pending inputs are sent as one await predict_batch(xs) call when the batch reaches max_batch items, or max_wait seconds after the batch's first item arrived, whichever comes first. A lone request must not wait for company forever.
  • predict_batch returns a list of outputs in input order. If it raises, every caller in that batch gets that exception, and other batches are unaffected. If it returns the wrong number of results, every caller in the batch gets a RuntimeError; never hand anyone a possibly misaligned row.
  • A caller that is cancelled (client disconnected, request timeout) before its batch is sent must not be sent to the model. Cancelling one caller must never affect the others.
  • await batcher.close() sends whatever is pending immediately and waits for in-flight batches (used at shutdown).

Signature: class MicroBatcher(Generic[X, Y]): def __init__(self, predict_batch: Callable[[list[X]], Awaitable[list[Y]]], max_batch: int, max_wait: float) -> None async def predict(self, x: X) -> Y async def close(self) -> None

Constraints: Single event loop (one per worker process). Batches may run concurrently with each other. Typical settings: max_batch=32, max_wait=0.005. Standard library only.

Examples:

  • max_batch=2; gather(predict(1), predict(2), predict(3), predict(4)) with a model returning x * 10 → [10, 20, 30, 40]; the model saw [[1, 2], [3, 4]]
  • max_batch=8, max_wait=0.05; a single predict(7) → 70, after about 50 ms, in a batch of one
  • max_batch=2; predict(1) and predict(-1) where the model raises on -1 → both callers get the ValueError; the next batch succeeds

Hints:

  1. Give each caller its own Future, and resolve it from the task that runs the batch.
  2. loop.call_later returns a handle. When the batch is flushed because it's full, what should happen to the timer started by its first item?
  3. When a caller awaiting a future is cancelled, what happens to the future? How can the flush notice?
  4. Who holds a reference to the batch task so it isn't garbage-collected mid-flight?

Reference solution:

import asyncio
from collections.abc import Awaitable, Callable
from typing import Generic, TypeVar

X = TypeVar("X")
Y = TypeVar("Y")


class MicroBatcher(Generic[X, Y]):
    """Group concurrent single predictions into batched model calls.

    A batch is sent when it reaches `max_batch` items, or `max_wait` seconds
    after its FIRST item arrived, whichever comes first. Each caller gets its
    own result; if the batch call fails, every caller in that batch gets the error.
    """

    def __init__(self, predict_batch: Callable[[list[X]], Awaitable[list[Y]]],
                 max_batch: int, max_wait: float) -> None:
        if max_batch < 1 or max_wait <= 0:
            raise ValueError("max_batch must be >= 1 and max_wait > 0")
        self._predict_batch = predict_batch
        self._max_batch = max_batch
        self._max_wait = max_wait
        self._pending: list[tuple[X, asyncio.Future[Y]]] = []
        self._timer: asyncio.TimerHandle | None = None
        self._running: set[asyncio.Task[None]] = set()     # keep references to batch tasks

    async def predict(self, x: X) -> Y:
        loop = asyncio.get_running_loop()
        fut: asyncio.Future[Y] = loop.create_future()
        self._pending.append((x, fut))
        if len(self._pending) >= self._max_batch:
            self._flush()
        elif self._timer is None:                           # first item of a new batch
            self._timer = loop.call_later(self._max_wait, self._flush)
        return await fut                                    # cancelling this cancels only `fut`

    def _flush(self) -> None:
        if self._timer is not None:
            self._timer.cancel()                            # the deadline belonged to THIS batch
            self._timer = None
        batch = [(x, f) for x, f in self._pending if not f.done()]   # drop cancelled callers
        self._pending = []
        if batch:
            task = asyncio.get_running_loop().create_task(self._run(batch))
            self._running.add(task)
            task.add_done_callback(self._running.discard)

    async def _run(self, batch: list[tuple[X, asyncio.Future[Y]]]) -> None:
        try:
            results = await self._predict_batch([x for x, _ in batch])
            if len(results) != len(batch):
                raise RuntimeError(f"model returned {len(results)} results for {len(batch)} inputs")
        except Exception as exc:
            for _, fut in batch:
                if not fut.done():
                    fut.set_exception(exc)
            return
        for (_, fut), y in zip(batch, results):
            if not fut.done():                              # caller may have given up meanwhile
                fut.set_result(y)

    async def close(self) -> None:
        """Send whatever is pending and wait for all in-flight batches."""
        self._flush()
        while self._running:
            await asyncio.gather(*self._running, return_exceptions=True)

Complexity: O(1) amortised per predict plus O(b) per batch of size b to build the input list and resolve futures. Memory is O(pending + in-flight items).

Tests:

import asyncio


class FakeModel:
    def __init__(self) -> None:
        self.batches: list[list[int]] = []
    async def predict_batch(self, xs: list[int]) -> list[int]:
        self.batches.append(list(xs))
        await asyncio.sleep(0.01)                 # GPU time
        if -1 in xs:
            raise ValueError("NaN in features")
        if 99 in xs:
            return [x * 10 for x in xs][:-1]      # buggy model server drops a row
        return [x * 10 for x in xs]


async def scenario() -> None:
    # size-triggered: 5 concurrent requests, batches of at most 2, results routed correctly
    m = FakeModel()
    b = MicroBatcher(m.predict_batch, max_batch=2, max_wait=5.0)
    out = await asyncio.wait_for(asyncio.gather(*(b.predict(i) for i in range(1, 5))), 1.0)
    assert out == [10, 20, 30, 40] and m.batches == [[1, 2], [3, 4]]

    # time-triggered: a lone request is not stuck waiting for company
    m = FakeModel()
    b = MicroBatcher(m.predict_batch, max_batch=8, max_wait=0.05)
    assert await asyncio.wait_for(b.predict(7), 1.0) == 70 and m.batches == [[7]]

    # the deadline belongs to the batch: a size flush cancels the old timer
    m = FakeModel()
    b = MicroBatcher(m.predict_batch, max_batch=3, max_wait=1.0)
    first = [asyncio.create_task(b.predict(i)) for i in (1, 2, 3)]     # flushed at t=0 by size
    await asyncio.sleep(0.4)
    d = asyncio.create_task(b.predict(4))                             # new batch, deadline t=1.4
    await asyncio.sleep(0.8)                                           # t=1.2: past the OLD deadline
    e = asyncio.create_task(b.predict(5))
    await asyncio.gather(*first, d, e)
    assert m.batches == [[1, 2, 3], [4, 5]], m.batches

    # a failing batch fails all of its callers, and only them
    m = FakeModel()
    b = MicroBatcher(m.predict_batch, max_batch=2, max_wait=5.0)
    r = await asyncio.gather(b.predict(1), b.predict(-1), b.predict(3), b.predict(4),
                             return_exceptions=True)
    assert [type(x).__name__ for x in r[:2]] == ["ValueError", "ValueError"] and r[2:] == [30, 40]

    # wrong number of results: everyone in the batch gets an error, nobody gets a wrong row
    r = await asyncio.gather(b.predict(5), b.predict(99), return_exceptions=True)
    assert all(isinstance(x, RuntimeError) for x in r)

    # a caller that gives up before the flush is not sent to the model
    m = FakeModel()
    b = MicroBatcher(m.predict_batch, max_batch=3, max_wait=0.05)
    gone = asyncio.create_task(b.predict(1))
    stay = asyncio.create_task(b.predict(2))
    await asyncio.sleep(0)
    gone.cancel()
    assert await stay == 20 and m.batches == [[2]]

    # close() flushes a partial batch without waiting for the deadline
    m = FakeModel()
    b = MicroBatcher(m.predict_batch, max_batch=10, max_wait=30.0)
    t = asyncio.create_task(b.predict(6))
    await asyncio.sleep(0)
    await asyncio.wait_for(b.close(), 1.0)
    assert await t == 60


asyncio.run(scenario())
print("all tests passed")

A common wrong solution (the tests catch it):

import asyncio
from collections.abc import Awaitable, Callable
from typing import Generic, TypeVar

X = TypeVar("X")
Y = TypeVar("Y")


class MicroBatcher(Generic[X, Y]):
    """Group concurrent single predictions into batched model calls.

    A batch is sent when it reaches `max_batch` items, or `max_wait` seconds
    after its FIRST item arrived, whichever comes first. Each caller gets its
    own result; if the batch call fails, every caller in that batch gets the error.
    """

    def __init__(self, predict_batch: Callable[[list[X]], Awaitable[list[Y]]],
                 max_batch: int, max_wait: float) -> None:
        if max_batch < 1 or max_wait <= 0:
            raise ValueError("max_batch must be >= 1 and max_wait > 0")
        self._predict_batch = predict_batch
        self._max_batch = max_batch
        self._max_wait = max_wait
        self._pending: list[tuple[X, asyncio.Future[Y]]] = []
        self._timer: asyncio.TimerHandle | None = None
        self._running: set[asyncio.Task[None]] = set()     # keep references to batch tasks

    async def predict(self, x: X) -> Y:
        loop = asyncio.get_running_loop()
        fut: asyncio.Future[Y] = loop.create_future()
        self._pending.append((x, fut))
        if len(self._pending) >= self._max_batch:
            self._flush()
        elif self._timer is None:                           # first item of a new batch
            self._timer = loop.call_later(self._max_wait, self._flush)
        return await fut                                    # cancelling this cancels only `fut`

    def _flush(self) -> None:
        self._timer = None                                  # forgets to cancel the old timer
        batch = list(self._pending)
        self._pending = []
        if batch:
            task = asyncio.get_running_loop().create_task(self._run(batch))
            self._running.add(task)
            task.add_done_callback(self._running.discard)

    async def _run(self, batch: list[tuple[X, asyncio.Future[Y]]]) -> None:
        try:
            results = await self._predict_batch([x for x, _ in batch])
            if len(results) != len(batch):
                raise RuntimeError(f"model returned {len(results)} results for {len(batch)} inputs")
        except Exception as exc:
            for _, fut in batch:
                if not fut.done():
                    fut.set_exception(exc)
            return
        for (_, fut), y in zip(batch, results):
            if not fut.done():                              # caller may have given up meanwhile
                fut.set_result(y)

    async def close(self) -> None:
        """Send whatever is pending and wait for all in-flight batches."""
        self._flush()
        while self._running:
            await asyncio.gather(*self._running, return_exceptions=True)

Alternatives and tradeoffs:

  • A queue and a single consumer task: predict puts (x, future) on an asyncio.Queue; a background loop does first = await q.get(), then gathers more with asyncio.wait_for(q.get(), remaining) until full or the deadline passes. That gives one batch in flight at a time (natural backpressure toward a single GPU) and easier shutdown via a sentinel, but you must start and stop the loop with the app's lifespan.
  • Server-side dynamic batching: NVIDIA Triton, TorchServe and Ray Serve (@serve.batch) do exactly this inside the model server. If you control the serving stack, prefer that to batching in every client.
  • Tuning: max_wait adds directly to p50 latency at low traffic, while max_batch caps the GPU efficiency gain at high traffic. Measure throughput and p99 at your real request rates and choose per model. Many teams adapt max_wait to load.
  • The broken version forgets to cancel the first item's timer when a batch is flushed for being full, so the stale timer later flushes the next batch early (smaller batches than configured), and it sends cancelled callers to the model, wasting GPU time.

Follow-ups:

  1. "Your service runs 4 Uvicorn worker processes, each with its own batcher. At low traffic, batches are mostly of size 1. Why, and what would you do?" Requests are spread across 4 independent batchers, so each sees a quarter of the traffic and its max_wait window rarely catches a second request. Options: fewer workers per pod (async I/O doesn't need many when the GPU call is awaited), move batching into the model server so all clients share one batcher, or accept it, since at low traffic there's GPU headroom anyway and batching matters only at peak. Check the batch-size histogram metric before optimising.
  2. "What metrics would you emit from this component?" A batch-size histogram, the time each item waited in the queue (time from predict to flush), model call latency, batch error count by exception type, the number of cancelled callers dropped before flush, and the in-flight batch count. Queue wait plus model latency explains the endpoint's p99; a rising queue wait with full batches means you are throughput-bound and need more replicas.
  3. "The model call itself hangs for 30 seconds. What happens to callers, and what should?" Every caller in that batch hangs too, and with unbounded concurrent batches more pile up behind a sick GPU. Wrap predict_batch in asyncio.timeout(...) so the whole batch fails fast with a TimeoutError, cap in-flight batches with a semaphore so a hung model applies backpressure instead of piling up work, and let the endpoint map the timeout to a 503 so the load balancer can retry on another replica (scoring is read-only, so it is safe to retry).

Scoring: Strong: Per-caller futures; size flush cancels the pending timer; deadline measured from the batch's first item; cancelled callers filtered at flush and done() checked before setting results; errors and length mismatches fanned out to the batch only; task references kept; close() implemented. Discusses latency/throughput tuning, metrics and timeouts. About 35 minutes. Acceptable: Working size- and time-triggered batching with correct result routing, but misses the stale-timer bug or cancelled callers; fixes them when the test is shown. Red flag: Awaits the model call inline in the caller that fills the batch (so that caller's cancellation cancels everyone's batch), or sleeps max_wait on every request; can't explain how results are routed back to the right caller.

SR-MT-C02 · Safe commit offsets for a parallel Kafka consumer

Medium & Tricky · Coding · Senior (5–8 years) · stream processing, at-least-once delivery, offset management, concurrency, redelivery, data loss · 30 min

Problem:

A consumer reads one Kafka partition of payment events and hands each message to a pool of worker threads, so messages finish out of order. Periodically it commits an offset, and after a crash or rebalance it resumes from the message after the committed one. Write the bookkeeping class that decides what is safe to commit.

  • OffsetTracker(committed): committed is the last offset whose effects are already durable.
  • start(offset) -> bool: called when a message is received. Return False (skip it) if it is a redelivery: at or below the commit point, currently in flight, or already finished. Otherwise record it as in flight and return True.
  • finish(offset): the message's side effects are durable.
  • commit_point() -> int: advance and return the highest offset that is safe to commit. Never commit past a message that is still in flight, because if the process dies, that message would never be processed. The commit point never moves backwards.

Offsets in a partition increase but are not necessarily contiguous: compaction and transaction markers leave gaps, so offset 105 may simply not exist. Messages arrive in offset order, but redeliveries can repeat earlier ones.

Signature: class OffsetTracker: def __init__(self, committed: int) -> None def start(self, offset: int) -> bool def finish(self, offset: int) -> None def commit_point(self) -> int

Constraints: One tracker per partition, called from the consumer's poll loop (single thread); workers report completion back to that loop. Thousands of messages in flight at most. Kafka itself stores the next offset to read, so the poll loop commits commit_point() + 1. Standard library only.

Examples:

  • OffsetTracker(99); start 100..103; finish 101, 103; commit_point() → 99: 100 is still running
  • then finish(100); commit_point() → 101: 102 is still running
  • offsets 104, 106, 108 (105 and 107 don't exist); finish 104 and 108; commit_point() → 105: just below the oldest in-flight offset (106)

Hints:

  1. What is the smallest offset you must not commit past?
  2. If nothing is in flight, what is the right commit point, given that the offsets may have gaps?
  3. Which data structure tells you the oldest in-flight offset cheaply? Is a set good enough at this scale?
  4. Why is 'commit the highest finished offset' the classic data-loss bug?

Reference solution:

class OffsetTracker:
    """Decide which offset of ONE partition is safe to commit when messages
    finish out of order (a worker pool) and the broker may redeliver.

    `committed` is the last offset whose effects are durable; on restart the
    consumer resumes after it. Offsets increase but need not be contiguous
    (compaction and transaction markers leave gaps).
    """

    def __init__(self, committed: int) -> None:
        self.committed = committed
        self._in_flight: set[int] = set()
        self._done_max = committed            # highest finished offset so far

    def start(self, offset: int) -> bool:
        """Register a received message. False means: skip it, it's a redelivery."""
        if offset <= self.committed or offset in self._in_flight:
            return False
        if offset <= self._done_max:          # already finished above the commit point
            return False                      # (only correct if offsets are never reused)
        self._in_flight.add(offset)
        return True

    def finish(self, offset: int) -> None:
        """Mark a started message as fully processed (side effects durable)."""
        self._in_flight.remove(offset)        # KeyError on an unknown offset: a caller bug
        self._done_max = max(self._done_max, offset)

    def commit_point(self) -> int:
        """Advance and return the highest offset that is safe to commit."""
        if self._in_flight:
            safe = min(self._in_flight) - 1   # never pass the oldest unfinished message
        else:
            safe = self._done_max
        self.committed = max(self.committed, safe)
        return self.committed

Complexity: start/finish: O(1). commit_point: O(k) for k messages in flight using min() over a set; use a heap with lazy deletion (or a sorted container) to make it O(log k) if k is large. Space O(k).

Tests:

t = OffsetTracker(committed=99)
for off in (100, 101, 102, 103):
    assert t.start(off)

# 101 and 103 finish first: 100 is still running, so nothing can be committed
t.finish(101); t.finish(103)
assert t.commit_point() == 99
# 100 finishes: commit up to 101 only, because 102 is still in flight
t.finish(100)
assert t.commit_point() == 101
t.finish(102)
assert t.commit_point() == 103

# gaps in offsets (compacted topic): 105 and 107 don't exist
assert t.start(104) and t.start(106) and t.start(108)
t.finish(104); t.finish(108)
assert t.commit_point() == 105            # 106 is still running; 105 never existed, harmless
t.finish(106)
assert t.commit_point() == 108

# redelivery after a rebalance: already committed -> skip, not reprocessed
assert t.start(103) is False and t.start(108) is False
# a duplicate of a message that is still being processed -> skip
assert t.start(109) and t.start(109) is False
# duplicate of a message finished above the commit point (110 done, 109 not)
assert t.start(110)
t.finish(110)
assert t.commit_point() == 108
assert t.start(110) is False
t.finish(109)
assert t.commit_point() == 110
# commit point never goes backwards
assert t.commit_point() == 110 and t.committed == 110

# a fresh tracker after restart starts from the committed offset
t2 = OffsetTracker(committed=110)
assert t2.commit_point() == 110 and t2.start(111)
print("all tests passed")

A common wrong solution (the tests catch it):

class OffsetTracker:
    # Commits the highest FINISHED offset. Fast and simple, but if the process
    # dies, anything below it that was still running is never reprocessed.
    def __init__(self, committed: int) -> None:
        self.committed = committed
        self._seen: set[int] = set()

    def start(self, offset: int) -> bool:
        if offset <= self.committed or offset in self._seen:
            return False
        self._seen.add(offset)
        return True

    def finish(self, offset: int) -> None:
        self.committed = max(self.committed, offset)

    def commit_point(self) -> int:
        return self.committed

Alternatives and tradeoffs:

  • Heap of in-flight offsets with lazy deletion makes commit_point O(log k); worth it only with tens of thousands in flight.
  • Per-key ordering instead of per-partition: libraries such as Confluent's parallel consumer process different keys in parallel but the same key in order, and track offsets the same way, encoding completed offsets above the commit point in the commit metadata so fewer messages are reprocessed after a restart.
  • Commit only after a batch fully completes (process a poll batch, wait for all of it, commit its last offset). Much simpler and often fast enough; you give up some parallelism because one slow message holds up the batch.
  • The broken version commits the highest finished offset. It looks fine in a happy-path test, but if the process dies while 100 is still running and 103 is committed, message 100 is never processed: silent data loss.

Follow-ups:

  1. "After a crash, a new consumer resumes from the commit point and reprocesses 100–103, even though 101 and 103 had finished. Is that a bug?" It's the at-least-once contract: anything above the commit point may be redelivered, and this tracker's in-memory 'finished' set is lost with the process. So the processing itself must be idempotent: deduplicate on an event ID in the same transaction as the side effect, or make the write a version-guarded upsert. The tracker minimises duplicates in the normal case; idempotency makes duplicates harmless in the crash case.
  2. "One message takes 10 minutes (a slow downstream). What happens to commits and to the consumer, and what do you do?" The commit point is stuck just below it, so lag appears to grow even though other messages are processed, and a crash would replay 10 minutes of work. Also, if in-flight work keeps growing, memory grows. Bound in-flight messages (pause the partition when the limit is reached), put a timeout on processing, and send messages that keep failing or timing out to a retry or dead-letter topic so they stop blocking the commit point. Alert on 'oldest in-flight age', not just consumer lag.
  3. "How does this change if the side effect is writing to Postgres, and you want effectively-once?" Store the consumed offset in the same Postgres transaction as the side effect (a table (topic, partition) → offset) and, on startup, seek to the stored offset instead of using Kafka's committed offset. Then the effect and the progress marker commit atomically, and a crash can't separate them. Kafka transactions give a similar guarantee only when both input and output are Kafka topics.

Scoring: Strong: Commit point = min(in-flight) − 1, or the highest finished offset when nothing is in flight; handles gaps, redeliveries and the never-backwards rule; explains why 'max finished' loses data; ties it to idempotent processing, bounded in-flight and dead-letter handling. About 30 minutes. Acceptable: Correct for contiguous offsets (advances while the next offset is done) but stalls on gaps; or misses duplicate detection for in-flight offsets; fixes with a hint about compaction. Red flag: Commits the latest finished or latest received offset, and doesn't see the data-loss scenario even when a crash is described.

SR-MT-C03 · Bulk API client with partial failures and stable idempotency keys

Medium & Tricky · Coding · Senior (5–8 years) · idempotency, retries, partial failure, exponential backoff, API clients, backfills · 30 min

Problem:

A nightly job pushes settled orders to the finance team's ledger API in bulk. The API is partially failing by design: for each item it answers "ok", "retryable" (throttled, or a shard briefly down) or "rejected" (invalid, never retry). Sometimes the whole call raises ConnectionError, and then you don't know whether the server applied the items: a read timeout after the server committed looks exactly like a connect failure.

The API deduplicates on an idempotency_key per item. Write bulk_send(records, send, ...):

  • Each item sent is {"idempotency_key": key, "record": record}. send(items) returns {key: status} or raises ConnectionError.
  • Retry only items that are not settled: retryable ones, ones missing from the reply, and all of them after a ConnectionError. Never resend "ok" or "rejected" items.
  • Up to max_attempts calls in total, sleeping base_delay * 2 ** (attempt - 1) between attempts (0.5, 1.0, 2.0, ...) via the injected sleep. Don't sleep after the last attempt or when nothing is left.
  • Return {order_id: "ok" | "rejected" | "failed"}, where "failed" means attempts were exhausted.
  • A retry after an ambiguous failure, and a re-run of the whole job tomorrow, must not apply any record twice.

Also write idempotency_key(record).

Signature: def idempotency_key(record: dict) -> str: def bulk_send(records: list[dict], send: Callable[[list[dict]], dict[str, str]], *, max_attempts: int = 4, base_delay: float = 0.5, sleep: Callable[[float], None] = time.sleep) -> dict[str, str]:

Constraints: Records are JSON-serialisable dicts with a unique order_id within one call. Up to a few thousand records per call (chunking is the caller's job). Standard library only.

Examples:

  • 4 records, one with amount -5; the API answers normally → {'O0': 'ok', 'O1': 'ok', 'O2': 'rejected', 'O3': 'ok'}; one call, no sleep
  • same, but O1 is 'retryable' once → second call contains only O1; sleep(0.5) once; O1 ends 'ok'
  • first call: the server applies everything, then the connection drops → the retry resends with the SAME keys; the server applies each order exactly once

Hints:

  1. When exactly must the key be created so that attempt 3 and tomorrow's re-run use the same one?
  2. What should a record's status be if it's missing from the server's reply?
  3. Derive the key from what? A random UUID per record, created once, survives retries, but does it survive a re-run of the job?

Reference solution:

import hashlib
import json
import time
from collections.abc import Callable


def idempotency_key(record: dict) -> str:
    """Same record -> same key, on every attempt and every re-run of the job."""
    canonical = json.dumps(record, sort_keys=True, separators=(",", ":"), default=str)
    return hashlib.sha256(canonical.encode()).hexdigest()[:32]


def bulk_send(records: list[dict], send: Callable[[list[dict]], dict[str, str]], *,
              max_attempts: int = 4, base_delay: float = 0.5,
              sleep: Callable[[float], None] = time.sleep) -> dict[str, str]:
    """Push records to a bulk API that may partially fail. Returns order_id -> final status.

    `send(items)` takes [{"idempotency_key": ..., "record": ...}] and returns
    {idempotency_key: "ok" | "retryable" | "rejected"}, or raises ConnectionError
    when the outcome of the whole call is unknown.
    Final statuses: "ok", "rejected" (never retried) or "failed" (attempts exhausted).
    """
    pending = {idempotency_key(r): r for r in records}       # keys fixed BEFORE the first attempt
    final: dict[str, str] = {}
    for attempt in range(1, max_attempts + 1):
        items = [{"idempotency_key": k, "record": r} for k, r in pending.items()]
        try:
            statuses = send(items)
        except ConnectionError:
            statuses = {}                     # unknown outcome: retry all of them, same keys
        for key in list(pending):
            status = statuses.get(key, "retryable")         # missing from the reply = unknown
            if status in ("ok", "rejected"):
                final[pending.pop(key)["order_id"]] = status
        if not pending:
            break
        if attempt < max_attempts:
            sleep(base_delay * 2 ** (attempt - 1))
    for record in pending.values():
        final[record["order_id"]] = "failed"
    return final

Complexity: O(n × attempts) to build batches, plus O(total record size) to hash each record once. O(n) extra space.

Tests:

class FakeLedgerAPI:
    """Dedups on idempotency_key, like a real payments/ledger API."""
    def __init__(self, script: list[str], flaky: set[str] = frozenset()) -> None:
        self.script = list(script)        # per call: "normal", "drop_after_apply", "down"
        self.flaky = set(flaky)           # order_ids that return "retryable" once
        self.seen_keys: dict[str, str] = {}
        self.applied: list[str] = []      # order_ids, once per real write
        self.batches: list[list[str]] = []

    def send(self, items: list[dict]) -> dict[str, str]:
        mode = self.script.pop(0) if self.script else "normal"
        self.batches.append([i["record"]["order_id"] for i in items])
        if mode == "down":
            raise ConnectionError("connect timeout")
        out = {}
        for item in items:
            key, rec = item["idempotency_key"], item["record"]
            if rec["amount"] <= 0:
                out[key] = "rejected"
            elif rec["order_id"] in self.flaky:
                self.flaky.discard(rec["order_id"])
                out[key] = "retryable"
            else:
                if key not in self.seen_keys:
                    self.seen_keys[key] = rec["order_id"]
                    self.applied.append(rec["order_id"])
                out[key] = "ok"
        if mode == "drop_after_apply":
            raise ConnectionError("read timeout")   # server applied, client never heard back
        return out


recs = [{"order_id": f"O{i}", "amount": a} for i, a in enumerate([100, 250, -5, 999])]

# happy path: one call, rejected record reported, nothing retried
api, slept = FakeLedgerAPI([]), []
assert bulk_send(recs, api.send, sleep=slept.append) == \
    {"O0": "ok", "O1": "ok", "O2": "rejected", "O3": "ok"}
assert len(api.batches) == 1 and slept == []

# partial failure: only the retryable record is sent again
api, slept = FakeLedgerAPI([], flaky={"O1"}), []
assert bulk_send(recs, api.send, sleep=slept.append)["O1"] == "ok"
assert api.batches[1] == ["O1"] and slept == [0.5]

# ambiguous failure: server applied, connection dropped -> retry must NOT double-apply
api, slept = FakeLedgerAPI(["drop_after_apply"]), []
result = bulk_send(recs, api.send, sleep=slept.append)
assert result == {"O0": "ok", "O1": "ok", "O2": "rejected", "O3": "ok"}
assert sorted(api.applied) == ["O0", "O1", "O3"], api.applied

# outage: exponential backoff, then "failed" without crashing the job
api, slept = FakeLedgerAPI(["down"] * 10), []
assert set(bulk_send(recs, api.send, max_attempts=4, sleep=slept.append).values()) == {"failed"}
assert slept == [0.5, 1.0, 2.0] and len(api.batches) == 4

# re-running the whole job later (e.g. a backfill) is also safe: same keys
api = FakeLedgerAPI([])
bulk_send(recs, api.send, sleep=lambda s: None)
bulk_send(recs, api.send, sleep=lambda s: None)
assert sorted(api.applied) == ["O0", "O1", "O3"]
assert idempotency_key({"a": 1, "b": 2}) == idempotency_key({"b": 2, "a": 1})
print("all tests passed")

A common wrong solution (the tests catch it):

import itertools
import time
from collections.abc import Callable

_counter = itertools.count()


def idempotency_key(record: dict) -> str:
    # A fresh key per send, like uuid4() inside the retry loop
    return f"{record['order_id']}-{next(_counter)}"


def bulk_send(records: list[dict], send: Callable[[list[dict]], dict[str, str]], *,
              max_attempts: int = 4, base_delay: float = 0.5,
              sleep: Callable[[float], None] = time.sleep) -> dict[str, str]:
    pending = list(records)
    final: dict[str, str] = {}
    for attempt in range(1, max_attempts + 1):
        items = [{"idempotency_key": idempotency_key(r), "record": r} for r in pending]
        try:
            statuses = send(items)
        except ConnectionError:
            statuses = {}
        still = []
        for item in items:
            status = statuses.get(item["idempotency_key"], "retryable")
            if status in ("ok", "rejected"):
                final[item["record"]["order_id"]] = status
            else:
                still.append(item["record"])
        pending = still
        if not pending:
            break
        if attempt < max_attempts:
            sleep(base_delay * 2 ** (attempt - 1))
    for record in pending:
        final[record["order_id"]] = "failed"
    return final

Alternatives and tradeoffs:

  • Key = business identity + version (f\"{order_id}:{settlement_version}\") instead of a content hash. It's more explicit and survives harmless changes such as a reordered or added field, but you need a reliable version. A content hash treats any change as a new operation: a corrected amount gets a new key and is applied again. For a ledger that may be exactly wrong, so decide deliberately which one you want.
  • uuid4() generated once per record and persisted (in an outbox table, say) before the first attempt: survives retries and re-runs, at the cost of a write.
  • Jitter: with many workers retrying at the same moment, add random jitter (delay * random.uniform(0.5, 1.5)) to avoid synchronised retry waves; inject the random source for tests.
  • Honour Retry-After if the API sends it on throttled items, instead of your own schedule.
  • The broken version builds keys inside the retry loop (as uuid4() per attempt would), so after an ambiguous ConnectionError the server sees new keys and applies the same orders twice.

Follow-ups:

  1. "The ledger API's dedup window is 24 hours. Your job's backfill re-sends a month of data. What happens?" Keys older than 24 hours have been forgotten, so the backfill applies everything again: idempotency keys protect retries, not replays beyond the window. For backfills you need idempotency in the data: the API should upsert on a natural key (order_id plus version) or reject duplicates permanently, or the job must check what's already in the ledger (a reconciliation diff) and send only the missing records. Ask the API owners which guarantee they offer; it's part of the contract.
  2. "How do you report the result of the job so that finance trusts it?" Emit counts by final status (ok / rejected / failed), the amount totals per status, and the list of rejected and failed order IDs with reasons to a dead-letter table for follow-up. Fail the job (non-zero exit, alert) if anything is failed; rejected is a data-quality issue routed to the owning team. Reconcile the totals against the source the next morning.
  3. "Twenty instances of this job run in parallel during a backfill and the ledger API starts throttling everyone. How do you keep it from getting worse?" Exponential backoff with jitter per instance, plus a global concurrency limit: fewer parallel workers, or a shared token bucket in Redis. Treat throttling as a signal to slow down overall, not just to retry this batch. A retry budget (stop retrying when more than X% of calls are retries) and a circuit breaker around the API stop a retry storm from making an outage worse.

Scoring: Strong: Keys fixed before the first attempt and stable across re-runs; retries only unsettled items; missing-from-reply treated as retryable; correct backoff schedule with no trailing sleep; failed status on exhaustion; discusses key derivation trade-offs (content hash vs business key), dedup windows vs backfills, and jitter or global rate limiting. Acceptable: Correct partial-failure retry and backoff, but derives keys per attempt or uses uuid4() per run; understands the ambiguous-failure double-apply once the drop-after-apply case is described. Red flag: Retries the whole batch, including ok and rejected items, with no keys, or treats ConnectionError as 'nothing happened'; no notion that a timeout can follow a successful write.

SR-MT-D01 · Outbound webhook delivery for 20,000 merchants

Medium & Tricky · Design · Senior (5–8 years) · webhooks, at-least-once delivery, retries and backoff, ordering, tenant isolation, queues, security, idempotency · 45 min

Scenario:

A payment gateway notifies merchants of events (payment.captured, payment.failed, refund.processed, settlement.completed) by POSTing JSON to a URL each merchant registers. There are ~20,000 active merchants and ~3 million events/day, peaking at ~500/s during sales. Merchant endpoints vary wildly: big merchants answer in 50 ms, while a small Shopify-style store in Indore might take 20 s or be down for a day.

Today a Django view fires the webhook synchronously right after the payment is captured, with requests.post(url, timeout=30) and no retries. Merchants complain about missed webhooks, and last week one merchant's endpoint hanging for 30 s tied up the workers and delayed payment captures for everyone.

Design a webhook delivery system in Python. Note that two goals pull against each other: retrying until delivered and delivering in order per payment.

Clarifying questions:

  • What delivery guarantee do we promise merchants: at-least-once with retries for how long? (At least once, retried for 72 hours.)
  • Do merchants need ordering? (Per payment, yes; refund.processed must not arrive before payment.captured for the same payment. No ordering across payments.)
  • Can merchants re-fetch event state from our API, and can they ask us to replay events?
  • How do merchants verify a webhook is genuinely from us?
  • Are some events more urgent than others (captures vs daily settlement summaries)?
  • What's the SLO? (p95 first-attempt delivery within 5 s of the event.)

Reference design:

Decouple from the payment path (transactional outbox): the capture transaction inserts the event into an outbox table in the same database transaction as the payment state change. A relay process publishes outbox rows to Kafka (or a Postgres-backed queue) and marks them sent. A capture can no longer be slowed by a merchant, and no event is lost if the process crashes after commit.

Delivery workers: async Python workers (asyncio + httpx.AsyncClient) consume events, sign and POST them. I/O-bound work with long waits is what asyncio is good at: one process can hold thousands of in-flight requests. Strict timeouts: connect 3 s, total 10 s. Response 2xx = delivered; 410 = endpoint gone (disable it); everything else, including timeouts, is retryable.

Retries without blocking others: a failed delivery is not retried inline. It's written to a retry schedule (a deliveries table with next_attempt_at, or delay topics such as 1 min, 5 min, 30 min, 2 h, 6 h) with exponential backoff plus jitter, up to 72 hours, then marked failed, alerted to the merchant (email or dashboard), and kept for manual replay.

The interaction: retries vs ordering. If event 1 for payment P is retrying and event 2 for P is delivered meanwhile, the merchant sees them out of order. Options: - Per-key ordering: partition by payment_id and don't deliver event N+1 for a payment while event N is pending. This preserves order, but one failing event blocks later events for that payment only. Implement it with a per-payment sequence number and a check that the previous sequence was delivered or abandoned. - Don't promise order; make it unnecessary (what Stripe and Razorpay largely do): include created_at, a per-payment sequence, and the full current object state in every event, and tell merchants to fetch the latest state from the API or ignore older sequences. Delivery stays fully parallel, and the merchant's handler becomes order-tolerant. - Recommended: the second as the contract, plus a per-payment sequence, which is cheap and turns ordering into the merchant's simple comparison rather than our head-of-line-blocking problem.

Tenant isolation (the other interaction: retries vs fairness): a merchant that is down generates a growing retry backlog, and a slow merchant holds connections. Apply per-merchant concurrency limits (e.g. at most 10 in flight per endpoint, an asyncio.Semaphore per merchant in each worker plus a global token bucket in Redis), and a per-endpoint circuit breaker: after N consecutive failures, park that merchant's deliveries until a probe succeeds. Separate queues or priority lanes for slow or failing merchants keep healthy merchants under the 5 s SLO.

Security and idempotency: HMAC-SHA256 signature over timestamp + "." + raw_body with a per-merchant secret (support two active secrets during rotation); a stable event_id header so merchants can deduplicate, since at-least-once means duplicates will happen. Block SSRF: resolve and reject private, loopback and link-local addresses for merchant URLs, re-checking on every delivery (DNS can change), and don't follow redirects.

Observability and operations: per-merchant success rate, latency, retry backlog and oldest pending event age; a merchant-facing delivery log with request and response snippets; a replay API (by event ID or time range), rate-limited.

Trade-offs:

  • Strict per-payment ordering vs throughput: blocking on the previous event guarantees order but creates head-of-line blocking and complex state; order-tolerant events with sequence numbers push a little work to merchants but keep delivery simple and parallel.
  • Kafka vs a Postgres queue (SELECT ... FOR UPDATE SKIP LOCKED): Kafka scales and gives partition ordering, but scheduled retries are awkward (delay topics). A Postgres deliveries table makes per-row next_attempt_at, per-merchant queries and replay trivial, and is plenty for ~35 events/s average and 500/s peaks with good indexes.
  • asyncio workers vs Celery with threads: asyncio handles thousands of slow sockets per process cheaply; Celery is familiar and has retries built in, but per-task retries with long countdowns sit in the broker and make per-merchant fairness harder.
  • Retry window length: longer windows recover more deliveries after merchant outages but grow the backlog and the replay storm when the merchant comes back. Rate-limit the drain.

Deep-dive probes:

  • "A big merchant was down for 6 hours and just came back; 400,000 queued events are now being delivered." Look for: draining at a controlled rate (the per-merchant limit, ramping up), delivering the newest state first or collapsing superseded events for the same payment, protecting other merchants' SLO, and coordinating with the merchant.
  • "A merchant says they received payment.captured twice and shipped the order twice." Look for: at-least-once being inherent (timeout after their server processed it), the event_id for deduplication, documentation and sample handler code, and checking our own duplicate rate (relay re-publishing outbox rows).
  • "Webhooks are delivered but payments are captured 2 s later than before." Look for: the outbox relay polling interval vs Postgres logical replication or CDC (Debezium), and confirming that the capture path no longer does any network I/O.
  • "A merchant registers http://169.254.169.254/latest/meta-data/ as their webhook URL." Look for: SSRF defences at registration and at delivery time (DNS rebinding), egress through a proxy that blocks internal ranges, no redirects, and response bodies truncated before being shown in the merchant dashboard.

Rubric: Strong: Removes delivery from the payment path via an outbox; scheduled retries with backoff and jitter instead of inline retries; explicitly resolves the retry-vs-ordering tension (sequence numbers plus full-state events, or per-key blocking with its cost) and the retry-vs-fairness tension (per-merchant limits, circuit breakers, isolation lanes); HMAC signing with rotation, event IDs for dedup, SSRF protection; operational tooling for replay and drain. Acceptable: Queue plus workers with retries and signatures, but treats ordering as free or ignores per-merchant isolation until probed; reaches reasonable answers when shown the slow-merchant and outage scenarios. Red flag: Keeps sending webhooks inside the payment request (or in a thread from it), retries with time.sleep inside a worker, promises exactly-once delivery, or has no answer for one slow merchant delaying everyone.

SR-MT-D02 · A month-end payout job that must run exactly once in effect

Medium & Tricky · Design · Senior (5–8 years) · scheduled jobs, distributed locks, leases, fencing tokens, idempotency, state machines, time zones, reliability · 45 min

Scenario:

A marketplace pays ~60,000 sellers at month-end. A Python job runs at 00:30 IST on the 1st: it computes each seller's payable amount from the ledger, calls the bank's payout API (NEFT/IMPS through a partner bank, ~5 calls/s allowed), and records the payout. The full run takes ~4 hours.

The job runs as a cron entry inside the app container. When the app was scaled to 3 replicas, sellers were paid three times. A quick fix added a Redis lock (SET payout_lock 1 NX EX 3600). Last month, the lock expired after an hour while the job was still running (a slow bank), a second replica started the job, and ~4,000 sellers were paid twice, ₹1.8 crore that finance is still recovering.

Design the payout job so it is safe to schedule redundantly, safe to crash and restart, and never pays a seller twice for the same period. Two mechanisms interact: mutual exclusion with leases and idempotent effects.

Clarifying questions:

  • Does the bank's payout API accept a client reference or idempotency key, and does it reject duplicates? For how long? (It rejects a duplicate client_ref within 30 days.)
  • Can we query the bank for a payout's status by our reference? (Yes, a status API, eventually consistent within ~10 minutes.)
  • Is the payable amount fixed at a cut-off time (the ledger as of 23:59:59 IST on the last day) or computed at payment time?
  • What happens with sellers whose bank details fail validation? (They're held and retried after the seller fixes the details.)
  • Who needs to be able to see progress or stop the run? (Finance ops, via a dashboard and a kill switch.)
  • Do we have Postgres available for job state? (Yes, the ledger lives there.)

Reference design:

Principle: exclusion is an optimisation, idempotency is the guarantee. No lock with a timeout can guarantee a single runner: a process can pause (GC, a VM migration, a 20-minute bank call) past its lease and keep running. So design for "two runners may overlap" and make that harmless.

1. Freeze the plan, then execute it. Step one, as a single transaction: compute payables from the ledger as of the cut-off (2026-09-30 23:59:59 IST, stored as an aware UTC timestamp) and insert one row per seller into payouts(period, seller_id, amount_paise, status, client_ref, attempt, ...) with UNIQUE(period, seller_id). client_ref is deterministic: PAYOUT-2026-09-<seller_id>. Re-running the planning step inserts nothing new (ON CONFLICT DO NOTHING), so the plan is idempotent and auditable before any money moves.

2. Execute rows through a state machine: planned → submitting → submitted → paid | failed | held. A worker claims a batch with UPDATE ... SET status='submitting', lease_owner=:me, lease_until=now()+5 min WHERE status='planned' ... FOR UPDATE SKIP LOCKED, so several workers can run in parallel without claiming the same seller. It calls the bank with client_ref, then sets submitted with the bank's reference.

3. The ambiguous window. If a worker dies after calling the bank but before recording submitted, the row stays submitting with an expired lease. A recovery step must not blindly resubmit. It queries the bank's status API by client_ref first: found → mark submitted; not found (and older than the bank's consistency window) → resubmit with the same client_ref, which the bank deduplicates anyway. Two layers of protection: our state machine and the bank's idempotency.

4. Leader election only for scheduling. Run the trigger from one place: a Kubernetes CronJob with concurrencyPolicy: Forbid, or an orchestrator such as Airflow, instead of cron in every replica. If a lock is still wanted (to avoid wasted work), use a lease with heartbeats (renew every 30 s, expire after 2 min) and fencing tokens: each acquisition gets an increasing number (a Postgres sequence), and every state transition includes WHERE fence <= :my_token and records the token, so a paused old holder's late writes are rejected. Postgres advisory locks (pg_try_advisory_lock) are another option: held per session, released automatically if the connection dies.

5. Rate and safety controls: a token bucket at the bank's 5 calls/s; a per-run cap on total amount (refuse to execute a plan whose total deviates more than X% from last month without approval); a kill switch checked between batches; a dry-run mode.

Observability: counts and amounts per status, rate of bank calls, rows stuck in submitting, the reconciliation result (our submitted vs the bank's statement next morning), and an alert on any client_ref the bank reports as a duplicate, which should never happen.

Trade-offs:

  • Redis SET NX EX lock vs Postgres advisory lock vs a CronJob with Forbid: Redis locks are easy but time-based (the incident); advisory locks tie exclusion to a live DB session (no TTL guessing), but a stuck-but-connected process keeps it forever; the scheduler-level Forbid prevents most overlaps without application code. None of them replaces idempotency.
  • One big job vs a planned queue of rows: a monolithic loop is simple but restarts from scratch or needs ad-hoc checkpoints; the plan table makes progress, parallelism and restarts natural, at the cost of a schema and a state machine.
  • Freezing amounts at plan time vs computing at execution: freezing gives auditability and consistency (finance approves a known total) but needs a correction flow for late adjustments (carried into next month's payout); computing at execution is fresher but makes reruns non-deterministic.
  • Parallel workers vs a single worker: parallelism shortens the 4-hour window, but the bank's rate limit is the real bottleneck; beyond that, parallelism only adds coordination risk.

Deep-dive probes:

  • "Walk me through exactly how last month's double payment happened, and show me that your design survives the same sequence." Look for: lease expiry with the holder still alive, a second runner starting, both calling the bank for the same sellers; then the fix: a deterministic client_ref deduplicated by the bank, row claiming with SKIP LOCKED, fenced state transitions, recovery that checks status before resubmitting.
  • "The job crashed at 02:10 after 35,000 sellers. What happens when it restarts at 02:15?" Look for: rows in planned continue; rows in submitting go through status-check recovery; no re-planning changes amounts; progress visible on the dashboard.
  • "Cron says 00:30 on the 1st, but the container runs in UTC." Look for: the 00:30 IST run is 19:00 UTC on the last day of the previous month, so the schedule must be expressed in IST (CronJob timeZone: Asia/Kolkata) and the cut-off computed with zoneinfo, not naive datetimes. A period key derived from the UTC date would point at the wrong month.
  • "Finance wants to re-run payouts for 300 sellers whose bank details were fixed." Look for: held → planned transitions for just those rows with the same client_ref (or a new attempt suffix if the first attempt definitively failed at the bank), an audit trail, and approval before execution.

Rubric: Strong: States that locks with TTLs can't guarantee single execution and builds correctness on idempotency: a frozen plan with a unique key per seller and period, deterministic bank references, a claim-and-execute state machine, and status-check recovery for the ambiguous window; uses leases with heartbeats and fencing tokens (or advisory locks) only as an optimisation; handles IST scheduling and cut-offs correctly; adds amount guards, a kill switch and reconciliation. Acceptable: Proposes a better lock (longer TTL with renewal, or advisory locks) plus a per-seller 'paid' flag checked before paying; reaches deterministic references and the ambiguous-failure recovery with probing. Red flag: Fixes the incident by increasing the lock TTL to 6 hours, checks 'already paid?' with a read-then-write outside a transaction, or generates a new bank reference on every attempt.

Hard (H)

SR-H-O01 · A "small" embedding cache that holds 234 MB

Hard · Output · Senior (5–8 years) · memory, numpy views, base arrays, tracemalloc, memory leaks, MLE · 8 min

Question: A recommender service keeps the last 100 embedding rows of each recent day in a dict "to save memory". The pod's RSS keeps climbing. What does this print? (tracemalloc tracks NumPy's data buffers; the numbers are rounded to whole MB.)

import tracemalloc
import numpy as np

tracemalloc.start()

def mb():
    return round(tracemalloc.get_traced_memory()[0] / 2**20)

def load_day(day):
    # one day of embeddings for a recommender: 20_000 x 512 float64 = ~78 MB
    return np.full((20_000, 512), float(day))

recent = {}
for day in range(1, 4):
    emb = load_day(day)
    recent[day] = emb[-100:]          # keep only the last 100 rows "to save memory"
    del emb
print("views:", mb(), "MB", recent[3].shape, recent[3].nbytes, recent[3].base is not None)

recent = {day: load_day(day)[-100:].copy() for day in range(1, 4)}
print("copies:", mb(), "MB", recent[3].nbytes, recent[3].base is None)

big = load_day(9)
col = big[:, 0]
print(col.flags["C_CONTIGUOUS"], col.strides, np.shares_memory(col, big))

Expected output:

views: 234 MB (100, 512) 409600 True
copies: 1 MB 409600 True
False (4096,) True

Explanation:

Basic slicing returns a view. emb[-100:] is a new ndarray header (shape, strides, offset) that points into the same data buffer as emb, and keeps a reference to it in .base. del emb only removes the name; the 78 MB buffer stays alive as long as any view of it is alive. Three days × 20,000 × 512 × 8 bytes ≈ 234 MB, even though each cached array reports nbytes == 409600 (400 KB). nbytes describes the view's logical size, not the memory it pins.

.copy() allocates a fresh, contiguous 400 KB buffer and owns it (base is None), so each 78 MB day is freed as soon as the temporary from load_day goes away. Three copies ≈ 1.2 MB, which rounds to 1.

The last line is the same story for a column: big[:, 0] is a strided view (strides == (4096,), one row of 512 × 8 bytes between elements), not contiguous, and it shares (and pins) the whole 78 MB array.

Production shape of this bug: caching slices of large arrays, DataFrame columns backed by a big block, or bytes slices via memoryview. Memory "leaks" with no Python-level reference cycle and no growing object count, so object counters and gc show nothing.

Takeaway: when a small slice must outlive a big array, .copy() it (or np.ascontiguousarray); check .base, not nbytes.

Follow-ups:

  1. "How would you find this in a running service where you can't add prints?" Compare tracemalloc snapshots (snapshot.compare_to(old, "lineno")) to see the allocation site that keeps growing: here it points at np.full in load_day, not at the cache line, which is the clue that something else pins it. For RSS vs Python heap, use memray or py-spy dump plus a heap snapshot; a big gap between RSS and tracemalloc usually means native buffers or fragmentation.
  2. "Which NumPy operations return views and which return copies?" Views: basic slicing, reshape when possible, .T, ravel when contiguous, view(). Copies: fancy (integer array) indexing, boolean masks, flatten(), most arithmetic. So emb[[-1, -2]] would have copied; use np.shares_memory in tests when it matters.
  3. "Does the same thing happen with pandas?" Yes, in subtler ways: a column or a row slice can be backed by a larger block. Since pandas 2.x with Copy-on-Write semantics, mutation is safe, but memory retention is not solved: call .copy() on a small subset you keep long-term (for example a per-merchant cache), and measure with df.memory_usage(deep=True) plus tracemalloc.

Scoring: Strong: Gets both numbers, explains .base pinning vs nbytes, names .copy() as the fix, and describes how they'd diagnose it with snapshots in production. Acceptable: Knows slices are views and reaches ~234 MB with prompting; unsure about the strided column line. Red flag: Believes del emb frees the 78 MB, or trusts nbytes/sys.getsizeof as a measure of what an array keeps alive.

SR-H-O02 · Deprecating a function in an SDK that 40 teams import

Hard · Output · Senior (5–8 years) · library design, warnings, DeprecationWarning, stacklevel, warning filters, API evolution · 8 min

Question: You maintain the internal paysdk package. You added deprecation warnings to two functions a quarter ago, but no team has migrated, and several say they "never saw a warning". showwarning is replaced so warnings print to stdout. The interpreter runs with default warning filters (no -W flag, no PYTHONWARNINGS). What does this print?

import sys
import types
import warnings

def show(message, category, filename, lineno, file=None, line=None):
    print(f"  shown: {category.__name__}: {message}")

warnings.showwarning = show

def make_module(name, source):
    mod = types.ModuleType(name)
    exec(source, mod.__dict__)
    sys.modules[name] = mod
    return mod

paysdk = make_module("paysdk", '''
import warnings
def charge(amount, currency="INR"):
    warnings.warn("charge() is deprecated; use create_payment()", DeprecationWarning)
    return amount
def refund(amount):
    warnings.warn("refund() is deprecated; use create_refund()", DeprecationWarning, stacklevel=2)
    return amount
''')

orders = make_module("orders", '''
import paysdk
def checkout():
    return paysdk.refund(10)
''')

print("app calls charge")
paysdk.charge(100)
print("app calls refund twice")
for _ in range(2):
    paysdk.refund(50)
print("another team's module calls refund")
orders.checkout()
print("under pytest-like filters")
with warnings.catch_warnings():
    warnings.simplefilter("error", DeprecationWarning)
    try:
        orders.checkout()
    except DeprecationWarning as exc:
        print("  raised:", exc)

Expected output:

app calls charge
app calls refund twice
  shown: DeprecationWarning: refund() is deprecated; use create_refund()
another team's module calls refund
under pytest-like filters
  raised: refund() is deprecated; use create_refund()

Explanation:

Three rules decide what a user sees:

  1. Default filters ignore DeprecationWarning unless it is attributed to __main__. Since Python 3.7 the default list is roughly default::DeprecationWarning:__main__, then ignore::DeprecationWarning. The audience is the developer running a script, not end users of an application.
  2. stacklevel decides which module the warning is attributed to. charge uses the default stacklevel=1, so the warning points at paysdk itself, matches ignore, and nobody ever sees it. refund uses stacklevel=2, so the warning points at the caller. Called from __main__, it is shown; called from orders (another team's library code), it is attributed to orders and ignored again.
  3. The default action shows a warning once per location (module + line). The loop calls refund twice from the same line, so it prints once.

Under simplefilter("error", DeprecationWarning) (what pytest -W error::DeprecationWarning or a strict CI does), the same call raises, which is how deprecations actually get noticed.

Takeaway: always pass stacklevel=2 (or more, through wrappers; 3.12 adds skip_file_prefixes), and don't rely on people seeing DeprecationWarning at runtime: make CI turn your package's deprecations into errors, and announce removals through the changelog and versioning policy.

Follow-ups:

  1. "Teams still won't see it in production logs. What would you do as the SDK owner?" Make it visible where it matters: ship a pytest plugin or documented filterwarnings = error::DeprecationWarning:paysdk.* setting for consumer CI; route warnings to logs with logging.captureWarnings(True) and count them as a metric (calls to deprecated APIs per service); publish a removal version under semver; and use static signals too, typing_extensions.deprecated / warnings.deprecated (PEP 702, 3.13+) so type checkers and IDEs flag calls.
  2. "When would you use FutureWarning or PendingDeprecationWarning instead?" FutureWarning is shown by default everywhere: use it for behaviour changes that end users of an application should see (pandas uses it for changing defaults). PendingDeprecationWarning is ignored even in __main__; it signals "will be deprecated later". DeprecationWarning targets developers of the calling code.
  3. "Your deprecated function is called through a decorator or a thin wrapper. What happens to stacklevel=2?" The warning gets attributed to the wrapper's module (inside your package) and is ignored, or points users to the wrong line. Compute the stacklevel for your own wrapper depth, or in 3.12+ use warnings.warn(..., skip_file_prefixes=(os.path.dirname(paysdk.__file__),)) so the first frame outside the package gets the blame.

Scoring: Strong: Exact output; explains the __main__-only default filter, the role of stacklevel, once-per-location dedup, and proposes a CI-enforced deprecation policy. Acceptable: Knows DeprecationWarning is hidden by default and that stacklevel=2 matters, but misses that a call from another library module is also hidden. Red flag: Expects every warning to print every time, or thinks adding a warning is enough to deprecate an API used by many teams.

SR-H-O03 · Keeping the last exception keeps the whole DataFrame alive

Hard · Output · Senior (5–8 years) · memory, exceptions, tracebacks, frames, weakref, reference counting · 8 min

Question: A nightly scoring job keeps self.last_error so a health endpoint can report it. After a few failed days, the worker is OOM-killed. Frame stands in for a 2 GB DataFrame, and a weak reference tells us whether it is still alive. What does this print? (CPython; frees happen when the reference count drops to zero.)

import traceback
import weakref

class Frame:                       # stands in for a 2 GB DataFrame
    def __init__(self, day):
        self.day = day

class Job:
    def __init__(self):
        self.last_error = None

    def score_day(self, day):
        df = Frame(day)
        self.probe = weakref.ref(df)
        raise ValueError(f"bad rows on {day}")

    def run(self, day, keep):
        try:
            self.score_day(day)
        except ValueError as exc:
            if keep == "exception":
                self.last_error = exc
            elif keep == "message":
                self.last_error = str(exc)
            elif keep == "cleared":
                traceback.clear_frames(exc.__traceback__)
                self.last_error = exc
        return self.probe() is not None

job = Job()
for keep in ["nothing", "message", "exception", "cleared"]:
    job.last_error = None
    print(f"{keep:<9} alive={job.run('2026-10-04', keep)}")

try:
    job.score_day("2026-10-05")
except ValueError as exc:
    pass
print("exc" in dir(), job.probe() is None)

Expected output:

nothing   alive=False
message   alive=False
exception alive=True
cleared   alive=False
False True

Explanation:

The reference chain is: exception → __traceback__ → traceback entries → frame objects → the frames' local variables. score_day's frame holds df, so as long as the exception object is reachable, the "2 GB" object is too.

  • nothing: when the except block ends, Python deletes exc (it compiles to finally: del exc). The exception's refcount reaches zero, the traceback and the score_day frame go with it, and so does df.
  • message: str(exc) keeps only a string. Nothing points to the frame.
  • exception: self.last_error = exc keeps the exception, its traceback and every frame in it, with all their locals. Each failed day pins another one: that's the OOM.
  • cleared: traceback.clear_frames(tb) clears the locals of every finished frame in the traceback (it skips frames that are still executing), so df is released while the exception, type and message remain.

The last line shows the language rule: exc doesn't exist after the except block ("exc" in dir() is False), precisely to avoid this cycle/retention; and nothing else kept the 2026-10-05 frame alive.

The same leak appears with sys.exc_info() stored on an object, a Future holding an exception, retry helpers that collect "all errors", and error-reporting code that stores tracebacks in a list.

Takeaway: store what you need from an exception (type, message, formatted traceback text, IDs), not the exception object, in anything long-lived.

Follow-ups:

  1. "The health endpoint needs the stack trace. How do you keep it without the frames?" Format it once: "".join(traceback.format_exception(exc)) (or traceback.TracebackException.from_exception(exc, capture_locals=False)), store the string, and let the exception go. Better still, log it with logger.exception(...) and send it to Sentry/your error tracker with an ID; the endpoint reports the ID.
  2. "Why didn't the garbage collector's cycle detector save us in the exception case?" There is no unreachable cycle to collect: job is reachable, and it holds the exception. GC only frees unreachable objects. (A real cycle also exists in the nothing case if code does e = exc inside the frame that raised; that one is collectable but only when the cyclic GC runs, which is why Python deletes exc eagerly.)
  3. "How would you confirm this is the leak in production?" Take two tracemalloc snapshots a day apart and compare by traceback: growth attributed to the DataFrame load site that never shrinks; then gc.get_referrers on one of the leaked objects (or objgraph.show_backrefs) shows the chain frame → traceback → exception → Job.last_error.

Scoring: Strong: All five lines; describes the exception → traceback → frame → locals chain, the implicit del exc, and what to store instead; mentions how they'd prove it with snapshots. Acceptable: Gets exception alive=True with reasoning about tracebacks, unsure about clear_frames or the last line. Red flag: Thinks an exception object is "just a message", or that GC will reclaim memory held by a reachable attribute.

SR-H-O04 · Loading a model artifact runs code before you see the model

Hard · Output · Senior (5–8 years) · security, pickle, deserialization, model serving, supply chain, allow-lists · 8 min

Question: A model-serving service downloads fraud-v7.pkl from a shared bucket that several teams can write to, and loads it with pickle.loads. A reviewer asks whether that's a security problem. What does this print?

import io
import json
import pickle

class Payload:
    def __reduce__(self):
        return (print, ("!! code ran during loads",))

blob = pickle.dumps({"model": "fraud-v7", "weights": Payload()})
print(len(blob) > 0, b"print" in blob)

print("loading...")
obj = pickle.loads(blob)
print("loaded:", obj)

class SafeUnpickler(pickle.Unpickler):
    ALLOWED = {("builtins", "dict"), ("builtins", "list")}
    def find_class(self, module, name):
        if (module, name) not in self.ALLOWED:
            raise pickle.UnpicklingError(f"blocked {module}.{name}")
        return super().find_class(module, name)

try:
    SafeUnpickler(io.BytesIO(blob)).load()
except pickle.UnpicklingError as exc:
    print("safe:", exc)

print(SafeUnpickler(io.BytesIO(pickle.dumps({"weights": [0.1, 0.2]}))).load())
print("json:", json.loads('{"weights": {"__reduce__": ["print", "pwned"]}}'))

Expected output:

True True
loading...
!! code ran during loads
loaded: {'model': 'fraud-v7', 'weights': None}
safe: blocked builtins.print
{'weights': [0.1, 0.2]}
json: {'weights': {'__reduce__': ['print', 'pwned']}}

Explanation:

A pickle is a small program for a stack machine, not a data format. __reduce__ lets an object say "to rebuild me, call this callable with these arguments". pickle.dumps records a reference to builtins.print (that's why b"print" is in the bytes) plus the argument tuple. pickle.loads imports the callable by name and calls it while loading; print returns None, which becomes the value of "weights".

An attacker who can write the file uses os.system, subprocess.Popen or eval instead of print: arbitrary code runs inside your serving process, with its credentials, at load time, before any validation of the "model". Nothing about the Payload class needs to exist on the loading side; the reference is to builtins.print.

SafeUnpickler.find_class is the hook every global lookup goes through. An allow-list (never a deny-list) of exactly the classes you expect blocks the payload with UnpicklingError, while plain containers still load.

json.loads only ever builds dicts, lists, strings, numbers, booleans and None; a key called "__reduce__" is just a string.

Takeaway: never unpickle data that crosses a trust boundary (shared buckets, caches, queues, user uploads). Use a data-only format (JSON, Parquet, safetensors, ONNX), or sign artifacts and verify the signature before loading.

Follow-ups:

  1. "The team says "the bucket is internal, only our CI writes to it". Is that enough?" It narrows the risk but makes the bucket's write permission equivalent to code execution in production. Treat it like a deploy pipeline: least-privilege write access, artifact signing (HMAC with a key the serving side holds, or Sigstore-style signatures) verified before load, checksums pinned in the deploy manifest, and audit logs.
  2. "Which common ML loaders have this problem?" Anything built on pickle: joblib.load, pickle, pandas.read_pickle, numpy.load(allow_pickle=True), and torch.load for full checkpoints (since PyTorch 2.6 weights_only=True is the default, which restricts the unpickler to tensors and primitive types). Prefer safetensors for weights and a versioned, data-only config for everything else.
  3. "Is an allow-listing find_class a complete fix?" It's a strong mitigation if the list is tiny and contains only data classes. It gets fragile when you have to allow large libraries (an allowed class with a dangerous __setstate__ or __reduce__ path reopens the hole), and it doesn't protect against resource exhaustion. Use it as defence in depth, not instead of a data-only format.

Scoring: Strong: Exact output; explains __reduce__ and that loads calls importable callables; proposes data-only formats, signing before load, and least-privilege on the artifact store. Acceptable: Knows pickle is unsafe for untrusted input and gets most of the output, but can't explain the mechanism or the find_class hook. Red flag: Believes unpickling only creates objects of classes defined in the loading code, or that an "internal" source makes pickle safe without further controls.

SR-H-C01 · Single-flight: one pricing call per key during a cache stampede

Hard · Coding · Senior (5–8 years) · concurrency, threading, caching, cache stampede, request coalescing, error propagation · 25 min

Problem:

At 12:00 IST a flash sale starts and the product-price cache entry for the hero SKU expires. Within the same millisecond, 400 request threads miss the cache and all call the pricing service, which falls over. You will put a single-flight layer in front of the loader (the pattern behind Go's singleflight and Guava's LoadingCache):

Implement SingleFlight with:

  • do(key, fn): if no call for key is in flight, run fn() in the calling thread and return its result. If a call for key is in flight, don't call fn; wait for the in-flight call and return the same result object, or raise the same exception it raised.
  • Nothing is cached: once the in-flight call finishes (success or failure), the next do(key, ...) runs fn again. (Caching is the caller's job; this only collapses concurrent duplicates.)
  • Calls for different keys must not block each other.
  • pending(key): how many callers are currently attached to the in-flight call for key (the leader plus waiters), 0 if none. Useful for metrics ("coalesced requests") and for tests.

Signature: class SingleFlight: def do(self, key: Hashable, fn: Callable[[], T]) -> T: ... def pending(self, key: Hashable) -> int: ...

Constraints:

  • Thread-safe under the standard (GIL) build and free-threaded builds: don't rely on the GIL for atomicity.
  • fn may take seconds and may raise any exception; never hold a lock while calling it.
  • Standard library only.

Examples:

  • 5 threads call sf.do('A1', load_price) while the first is still running → load_price runs once; all 5 threads get the same dict object
  • 3 threads call sf.do('C3', flaky) and flaky raises TimeoutError → all 3 threads raise TimeoutError; the next sf.do('C3', ...) calls fn again

Hints:

  1. You need a per-key record of the in-flight call: something to wait on, plus a slot for the result or the exception.
  2. A threading.Event per call lets followers wait without holding the global lock.
  3. Hold the lock only to look up / insert / remove the per-key record, never while running fn.
  4. Think about the failure path: what happens to the per-key record if fn raises? Use try/finally.
  5. Order matters at completion: store the result, remove the key, then wake the followers.

Reference solution:

import threading
from collections.abc import Callable, Hashable
from typing import Any, TypeVar

T = TypeVar("T")


class _Call:
    __slots__ = ("done", "result", "error", "callers")

    def __init__(self) -> None:
        self.done = threading.Event()
        self.result: Any = None
        self.error: BaseException | None = None
        self.callers = 1


class SingleFlight:
    """Coalesce concurrent calls for the same key into one execution.

    Callers that arrive while a call for `key` is in flight wait for it and get the
    same result (or the same exception). Nothing is cached: once the call finishes,
    the next caller for that key starts a new execution.
    """

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._calls: dict[Hashable, _Call] = {}

    def do(self, key: Hashable, fn: Callable[[], T]) -> T:
        with self._lock:
            call = self._calls.get(key)
            leader = call is None
            if leader:
                call = self._calls[key] = _Call()
            else:
                call.callers += 1

        if not leader:
            call.done.wait()
            if call.error is not None:
                raise call.error
            return call.result

        try:
            call.result = fn()            # run outside the lock: other keys stay unblocked
        except BaseException as exc:
            call.error = exc
            raise
        finally:
            with self._lock:
                del self._calls[key]      # always clean up, so failures are never cached
            call.done.set()               # wake followers only after result/error is stored
        return call.result

    def pending(self, key: Hashable) -> int:
        """Number of callers attached to the in-flight call for `key` (0 if none)."""
        with self._lock:
            call = self._calls.get(key)
            return call.callers if call is not None else 0

Complexity: O(1) bookkeeping per call under one lock, plus one Event per in-flight key. Memory is O(number of keys currently in flight); nothing is retained after completion.

Tests:

import threading
import time


def wait_until(cond, timeout=5.0):
    deadline = time.monotonic() + timeout
    while not cond():
        assert time.monotonic() < deadline, "condition not reached"
        time.sleep(0.001)


sf = SingleFlight()

# 1. Five concurrent callers for one key share a single load.
loads = []
release = threading.Event()

def load_price():
    loads.append("A1")
    assert release.wait(5)
    return {"sku": "A1", "price_inr": 499}

results = []
threads = [threading.Thread(target=lambda: results.append(sf.do("A1", load_price))) for _ in range(5)]
for t in threads:
    t.start()
wait_until(lambda: sf.pending("A1") == 5)

# 2. A different key is not blocked while A1 is in flight.
assert sf.do("B2", lambda: "b2-price") == "b2-price"

release.set()
for t in threads:
    t.join(5)
assert loads == ["A1"]
assert len(results) == 5 and all(r is results[0] for r in results)
assert sf.pending("A1") == 0

# 3. Nothing is cached: the next call runs again.
assert sf.do("A1", lambda: "fresh") == "fresh"

# 4. An error reaches every waiter, and is not cached either.
attempts = []
gate = threading.Event()

def flaky():
    attempts.append(1)
    assert gate.wait(5)
    raise TimeoutError("pricing service timed out")

errors = []

def caller():
    try:
        sf.do("C3", flaky)
    except TimeoutError as exc:
        errors.append(str(exc))

threads = [threading.Thread(target=caller) for _ in range(3)]
for t in threads:
    t.start()
wait_until(lambda: sf.pending("C3") == 3)
gate.set()
for t in threads:
    t.join(5)
assert len(attempts) == 1
assert errors == ["pricing service timed out"] * 3
assert sf.do("C3", lambda: "recovered") == "recovered"
assert sf.pending("C3") == 0

A common wrong solution (the tests catch it):

import threading


class _Call:
    def __init__(self):
        self.done = threading.Event()
        self.result = None
        self.error = None
        self.callers = 1


class SingleFlight:
    def __init__(self):
        self._lock = threading.Lock()
        self._calls = {}

    def do(self, key, fn):
        with self._lock:
            call = self._calls.get(key)
            leader = call is None
            if leader:
                call = self._calls[key] = _Call()
            else:
                call.callers += 1
        if not leader:
            call.done.wait()
            if call.error is not None:
                raise call.error
            return call.result
        try:
            call.result = fn()
        except Exception as exc:
            call.error = exc
            call.done.set()
            raise
        with self._lock:
            del self._calls[key]
        call.done.set()
        return call.result

    def pending(self, key):
        with self._lock:
            call = self._calls.get(key)
            return call.callers if call else 0

Alternatives and tradeoffs:

  • concurrent.futures.Future per key instead of _Call + Event: the leader calls set_result / set_exception, followers call future.result(). Same design, less code; it re-raises the same shared exception object too, so the traceback caveat below still applies.
  • Cache-level stampede protection: serve stale while one caller refreshes (stale-while-revalidate), add jitter to TTLs so hot keys don't expire together, or refresh ahead of expiry. These reduce how often you need single-flight; in a cluster they matter more than in-process coalescing.
  • Distributed single-flight: in-process coalescing collapses 400 threads to one call per pod. With 50 pods you still send 50 calls. A short Redis lock (SET key token NX PX 2000) with followers polling or reading stale data gets it to one call per cluster, at the cost of a network round trip and lock-expiry edge cases.
  • asyncio version: same shape with asyncio.Future; add care for cancellation (a cancelled leader must not leave followers waiting forever: shield the shared work or hand leadership over).

Follow-ups:

  1. "Followers re-raise the leader's exception object. What's the subtle problem with that?" Every raise of the same exception object appends the current frames to its __traceback__, so tracebacks from different threads get stitched together and grow (you can see this in the broken solution's failure output), and the object is shared mutable state across threads. Options: re-raise a new exception chained from it (raise PricingUnavailable(key) from call.error) so each thread gets its own exception object and the leader's original stays intact as __cause__.
  2. "The leader thread is killed by a timeout in the web framework while fn is running. What happens?" Python threads can't be killed, so fn keeps running and eventually completes, waking followers. The real risk is a hung fn: followers wait forever. Give fn its own timeout (HTTP client timeouts), and give followers a bounded done.wait(timeout) that raises a clear error so request threads are not pinned indefinitely.
  3. "Should errors be coalesced too, or should each follower retry on its own?" Coalesce them: if 400 callers each retry immediately after the leader fails, you recreate the stampede on a dependency that's already failing. Combine it with a short negative cache or a circuit breaker so the next wave also backs off.

Scoring: Strong: Correct leader/follower design with lock held only for bookkeeping, try/finally cleanup so errors are never cached, followers get the same result or exception, different keys stay independent; discusses cluster-wide stampedes and traceback sharing. Acceptable: Works for the success path and coalesces correctly, but needs a hint for the failure path (cleanup in finally) or holds the lock slightly too long. Red flag: Wraps the whole fn call in one global lock (serialising every key), or uses lru_cache and calls it stampede protection.

SR-H-C02 · Circuit breaker for a flaky UPI switch, with a single half-open trial

Hard · Coding · Senior (5–8 years) · resilience, circuit breaker, state machines, threading, injectable clocks, API design · 25 min

Problem:

Your payments service calls a bank's UPI switch. When the switch degrades, every request waits for a 5 s timeout, worker threads pile up, and the whole service goes down with it. Implement a circuit breaker:

  • closed (normal): calls go through. Count consecutive failures; a success resets the count. When the count reaches failure_threshold, the circuit opens.
  • open: calls fail fast with CircuitOpenError without calling the function, until reset_timeout seconds have passed since it opened.
  • half-open (after the timeout): exactly one trial call is let through. While that trial is running, other callers get CircuitOpenError. If the trial succeeds, the circuit closes (failure count back to 0). If it fails, the circuit opens again with a fresh timer.
  • Only exceptions in trip_on count as failures. Anything else (e.g. ValueError for an invalid VPA, which is a 4xx from your side) propagates unchanged and does not count; if it happens during the half-open trial, it proves the dependency answered, so the circuit closes.
  • state returns "closed", "open" or "half_open" (report "half_open" once the timeout has passed, even before a trial starts).
  • Time comes from an injectable clock (default time.monotonic), so tests don't sleep.

Signature: class CircuitBreaker: def __init__(self, failure_threshold: int, reset_timeout: float, *, trip_on: tuple[type[BaseException], ...] = (Exception,), clock: Callable[[], float] = time.monotonic) -> None: ... @property def state(self) -> str: ... def call(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: ...

Constraints:

  • Many threads share one breaker. Don't hold a lock while calling fn.
  • No sleeping or background threads; state transitions happen lazily inside call / state.
  • Standard library only.

Examples:

  • threshold=3: fail, fail, success, fail, fail → state == 'closed' (failures must be consecutive)
  • third consecutive ConnectionError, then call(up) 10 s later (reset_timeout=30) → CircuitOpenError; `up` is not called
  • 30 s after opening: a trial call is running and another thread calls → the second caller gets CircuitOpenError

Hints:

  1. Write the state machine on paper first: three states, and which event moves you between them.
  2. Open → half-open can be computed lazily from clock() - opened_at; no timer thread needed.
  3. To allow exactly one trial you need a flag that is set and checked under the same lock acquisition.
  4. Split call into before (decide/reserve), the call itself (no lock), and after (record outcome).
  5. A call that started while closed may fail after the circuit has already opened. Should it count again?
  6. Test the 'one trial only' rule without threads: have the trial function itself try a second call.

Reference solution:

import threading
import time
from collections.abc import Callable
from typing import Any, TypeVar

T = TypeVar("T")


class CircuitOpenError(Exception):
    """Raised without calling the dependency while the circuit is open."""


class CircuitBreaker:
    """Closed -> open after `failure_threshold` consecutive failures.

    Open -> half-open once `reset_timeout` seconds have passed; half-open lets exactly one
    trial call through (others are rejected) and its outcome closes or re-opens the circuit.
    Only exceptions in `trip_on` count as failures; anything else is the caller's problem
    (e.g. a 4xx validation error) and proves the dependency is up.
    """

    def __init__(self, failure_threshold: int, reset_timeout: float, *,
                 trip_on: tuple[type[BaseException], ...] = (Exception,),
                 clock: Callable[[], float] = time.monotonic) -> None:
        if failure_threshold < 1 or reset_timeout <= 0:
            raise ValueError("failure_threshold must be >= 1 and reset_timeout > 0")
        self._threshold = failure_threshold
        self._reset_timeout = reset_timeout
        self._trip_on = trip_on
        self._clock = clock
        self._lock = threading.Lock()
        self._state = "closed"
        self._failures = 0
        self._opened_at = 0.0
        self._trial_in_flight = False

    @property
    def state(self) -> str:
        with self._lock:
            if self._state == "open" and self._clock() - self._opened_at >= self._reset_timeout:
                return "half_open"
            return self._state

    def _before_call(self) -> bool:
        """Return True if this call is the half-open trial; raise if rejected."""
        with self._lock:
            if self._state == "closed":
                return False
            if self._state == "open":
                if self._clock() - self._opened_at < self._reset_timeout:
                    raise CircuitOpenError("circuit open")
                self._state = "half_open"
            if self._trial_in_flight:
                raise CircuitOpenError("circuit half-open: trial call in progress")
            self._trial_in_flight = True
            return True

    def _on_success(self, trial: bool) -> None:
        with self._lock:
            if trial:
                self._trial_in_flight = False
                self._state = "closed"
            if self._state == "closed":
                self._failures = 0

    def _on_failure(self, trial: bool) -> None:
        with self._lock:
            if trial:
                self._trial_in_flight = False
                self._trip()
                return
            if self._state != "closed":
                return                      # a slow call that started before the trip
            self._failures += 1
            if self._failures >= self._threshold:
                self._trip()

    def _trip(self) -> None:
        self._state = "open"
        self._opened_at = self._clock()
        self._failures = 0

    def call(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
        trial = self._before_call()
        try:
            result = fn(*args, **kwargs)    # never hold the lock while calling out
        except self._trip_on:
            self._on_failure(trial)
            raise
        except BaseException:
            if trial:                       # e.g. ValueError from bad input: dependency answered
                self._on_success(trial)
            raise
        self._on_success(trial)
        return result

Complexity: O(1) time per call; O(1) memory. One short critical section before and one after each call.

Tests:

class FakeClock:
    def __init__(self):
        self.now = 1000.0
    def __call__(self):
        return self.now

clock = FakeClock()
cb = CircuitBreaker(failure_threshold=3, reset_timeout=30, trip_on=(ConnectionError, TimeoutError), clock=clock)
calls = []

def down():
    calls.append("down")
    raise ConnectionError("UPI switch unreachable")

def up(x=1):
    calls.append("up")
    return x

def expect(exc_type, fn, *args):
    try:
        cb.call(fn, *args)
    except exc_type:
        return
    raise AssertionError(f"expected {exc_type.__name__}")

# Consecutive failures only: a success in between resets the count.
expect(ConnectionError, down)
expect(ConnectionError, down)
assert cb.call(up, 7) == 7
expect(ConnectionError, down)
expect(ConnectionError, down)
assert cb.state == "closed"

# Exceptions outside trip_on (bad input) don't count.
def bad_request():
    raise ValueError("invalid VPA")
expect(ValueError, bad_request)
expect(ValueError, bad_request)
assert cb.state == "closed"

expect(ConnectionError, down)            # third consecutive failure -> open
assert cb.state == "open"

# Open: fail fast without calling the dependency.
n = len(calls)
expect(CircuitOpenError, up)
assert len(calls) == n

clock.now += 29.9
expect(CircuitOpenError, up)
clock.now += 0.1
assert cb.state == "half_open"

# Half-open: exactly one trial; a concurrent caller is rejected while it runs.
def trial_that_sees_a_second_caller():
    expect(CircuitOpenError, up)
    raise TimeoutError("still slow")
expect(TimeoutError, trial_that_sees_a_second_caller)
assert cb.state == "open"                 # failed trial re-opens with a fresh timer
clock.now += 10
expect(CircuitOpenError, up)

clock.now += 20
inner = []
def good_trial():
    expect(CircuitOpenError, up)
    inner.append("rejected")
    return "ok"
assert cb.call(good_trial) == "ok"
assert inner == ["rejected"]
assert cb.state == "closed"

# After closing, the failure count starts from zero again.
expect(ConnectionError, down)
expect(ConnectionError, down)
assert cb.state == "closed"
assert cb.call(up, 3) == 3

A common wrong solution (the tests catch it):

import time


class CircuitOpenError(Exception):
    pass


class CircuitBreaker:
    def __init__(self, failure_threshold, reset_timeout, *, trip_on=(Exception,), clock=time.monotonic):
        self._threshold = failure_threshold
        self._reset_timeout = reset_timeout
        self._trip_on = trip_on
        self._clock = clock
        self._failures = 0
        self._opened_at = None

    @property
    def state(self):
        if self._opened_at is None:
            return "closed"
        if self._clock() - self._opened_at >= self._reset_timeout:
            return "half_open"
        return "open"

    def call(self, fn, *args, **kwargs):
        if self.state == "open":
            raise CircuitOpenError("circuit open")
        try:
            result = fn(*args, **kwargs)
        except self._trip_on:
            self._failures += 1
            if self._failures >= self._threshold:
                self._opened_at = self._clock()
            raise
        self._failures = 0
        self._opened_at = None
        return result

Alternatives and tradeoffs:

  • Failure-rate window instead of consecutive failures: open when more than 50% of the last N calls (or the last 10 seconds, with a minimum volume) failed. More robust when a dependency fails intermittently; this is what resilience4j and Envoy outlier detection do.
  • Slow calls as failures: count calls over a latency threshold, because a dependency that answers in 4.9 s hurts as much as one that errors.
  • Library: pybreaker or aiobreaker; in a service mesh, the sidecar (Envoy/Istio) can do it per upstream host. Then the in-process version only adds value for logic the mesh can't see (e.g. bank-specific error codes).
  • asyncio version: same state machine; the half-open flag works the same, and asyncio.CancelledError during the trial should release the trial slot without counting as a failure.

Follow-ups:

  1. "You run 40 pods. Should the breaker state be shared through Redis?" Usually not. Local breakers converge quickly because every pod sees the same failures, and they keep working when Redis is down. Shared state adds a network hop to every call and a new failure mode. Share only if traffic per pod is too low for a local breaker to ever see enough failures; then use a failure-rate window over shared counters.
  2. "What should the API return to the client when the circuit is open?" Fail fast with a clear, retryable error (HTTP 503 with Retry-After) or degrade gracefully (queue the payout, show "bank is slow, we'll confirm by SMS"). Never pretend success. Emit a metric and a log line on every state transition so on-call sees the breaker open before customers complain.
  3. "How do retries interact with the breaker?" Put the breaker outside the retry loop for user-facing paths (or make retries check the breaker) so that an open circuit stops retries immediately; and count one failure per logical call, not per retry attempt, or the breaker trips three times faster than you configured.

Scoring: Strong: Clean three-state machine with an injectable clock, single half-open trial enforced under a lock, no lock held during the call, correct handling of non-tripping exceptions; discusses failure-rate windows, metrics on transitions and the retry interaction. Acceptable: Closed/open/half-open work in the simple case but the half-open phase lets every caller through, or the failure count isn't reset properly; fixes it when the test shows it. Red flag: Uses time.sleep or a background thread to "close" the circuit, holds a lock across the remote call, or counts every exception (including bad input) as a dependency failure.

SR-H-C03 · CI gate: is this event-schema change safe for existing consumers?

Hard · Coding · Senior (5–8 years) · data contracts, schema evolution, backward compatibility, recursion, CI checks · 25 min

Problem:

The orders team publishes order_placed events consumed by 12 downstream jobs (finance, fraud, the data lake). Schemas live in the repo as dicts. You're adding a CI check that blocks a pull request if the new schema can break consumers built against the old one.

Rule: every record the producer can emit under new must also be valid under old. Consumers ignore unknown fields, so adding a field is never breaking.

A schema maps field name → spec: {"type": "int" | "float" | "str" | "bool" | "object", "required": bool (default True), "nullable": bool (default False), "enum": [...] (optional), "fields": {...} (only for "object")}.

Return a sorted list of messages, with nested paths joined by dots:

Situation (old → new) Message
required field missing from new "<path>: removed"
required → optional "<path>: became optional"
not nullable → nullable "<path>: became nullable"
type that accepts values old rejects "<path>: type <old> -> <new>" (don't check enum/nested after this)
old has an enum, new has none "<path>: enum constraint dropped"
new enum has values old lacks "<path>: enum values added [<sorted list>]"

Type compatibility: an int is a valid float (JSON consumers accept 5 where 5.0 is expected), so float → int is safe and int → float is breaking. bool is not an int, whatever Python's isinstance says. Removing an optional field is safe. Narrowing (optional → required, shrinking an enum, float → int) is safe.

Signature: def breaking_changes(old: Schema, new: Schema) -> list[str]:

Constraints:

  • Schemas are well-formed (validate them separately); nesting depth ≤ 10; up to a few hundred fields.
  • Pure function: don't mutate the inputs.

Examples:

  • old qty int, new qty float → ['qty: type int -> float']
  • old amount float, new amount int → []
  • new method enum adds 'UPI_LITE' → ["method: enum values added ['UPI_LITE']"]
  • address.pincode removed (required) → ['address.pincode: removed']

Hints:

  1. Walk the fields of old: a field that exists only in new can never break an old consumer.
  2. Write the type rule as data: for each new type, which old types accept all its values?
  3. Recurse into object fields with a path prefix, but only when the type check passed.
  4. Collect messages and sort once at the top level.

Reference solution:

from typing import Any

Field = dict[str, Any]
Schema = dict[str, Field]

# new type -> old types that accept every value of the new type
_ACCEPTED_BY = {
    "int": {"int", "float"},      # every int is a valid float for a JSON consumer
    "float": {"float"},
    "str": {"str"},
    "bool": {"bool"},             # bool is NOT an int here, even though Python says it is
    "object": {"object"},
}


def breaking_changes(old: Schema, new: Schema) -> list[str]:
    """List the ways records valid under `new` can break consumers built for `old`.

    The rule: every record the producer can emit under `new` must also be valid under
    `old`. Consumers ignore unknown fields, so adding a field is never breaking.
    Returns the messages sorted.
    """
    return sorted(_check(old, new, ""))


def _check(old: Schema, new: Schema, prefix: str) -> list[str]:
    problems: list[str] = []
    for name in sorted(old):
        path = f"{prefix}{name}"
        o = old[name]
        n = new.get(name)
        o_required = o.get("required", True)
        if n is None:
            if o_required:
                problems.append(f"{path}: removed")
            continue
        if o_required and not n.get("required", True):
            problems.append(f"{path}: became optional")
        if n.get("nullable", False) and not o.get("nullable", False):
            problems.append(f"{path}: became nullable")
        if o["type"] not in _ACCEPTED_BY[n["type"]]:
            problems.append(f"{path}: type {o['type']} -> {n['type']}")
            continue                  # nested/enum checks are meaningless after a type change
        if "enum" in o:
            if "enum" not in n:
                problems.append(f"{path}: enum constraint dropped")
            else:
                added = sorted(set(n["enum"]) - set(o["enum"]))
                if added:
                    problems.append(f"{path}: enum values added {added}")
        if o["type"] == "object":
            problems += _check(o["fields"], n["fields"], f"{path}.")
    return problems

Complexity: O(F log F) for F fields in total across all levels (one visit per field, plus sorting the messages).

Tests:

v1 = {
    "order_id": {"type": "str"},
    "amount": {"type": "float"},
    "qty": {"type": "int"},
    "coupon": {"type": "str", "required": False},
    "method": {"type": "str", "enum": ["UPI", "CARD", "NETBANKING"]},
    "address": {"type": "object", "fields": {
        "pincode": {"type": "str"},
        "landmark": {"type": "str", "required": False},
    }},
}

# Identical schema, and purely additive changes, are compatible.
assert breaking_changes(v1, v1) == []
added = dict(v1, gst_rate={"type": "float"}, channel={"type": "str", "required": False})
assert breaking_changes(v1, added) == []

# Narrowing is safe: float -> int, optional -> required, enum shrinks.
narrowed = dict(v1, amount={"type": "int"}, coupon={"type": "str"},
                method={"type": "str", "enum": ["UPI", "CARD"]})
assert breaking_changes(v1, narrowed) == []

# Dropping an OPTIONAL field is safe (top level and nested); consumers already handle its absence.
no_coupon = {k: v for k, v in v1.items() if k != "coupon"}
no_coupon["address"] = {"type": "object", "fields": {"pincode": {"type": "str"}}}
assert breaking_changes(v1, no_coupon) == []

# Widening and removal break consumers.
v2 = {
    "order_id": {"type": "str", "required": False},
    "amount": {"type": "float", "nullable": True},
    "qty": {"type": "float"},
    "method": {"type": "str", "enum": ["UPI", "CARD", "NETBANKING", "UPI_LITE"]},
    "address": {"type": "object", "fields": {
        "pincode": {"type": "int"},
    }},
}
assert breaking_changes(v1, v2) == [
    "address.pincode: type str -> int",
    "amount: became nullable",
    "method: enum values added ['UPI_LITE']",
    "order_id: became optional",
    "qty: type int -> float",
], breaking_changes(v1, v2)

# Required field removed, enum dropped, bool is not an int, nested removal.
v3 = {
    "amount": {"type": "float"},
    "qty": {"type": "bool"},
    "method": {"type": "str"},
    "coupon": {"type": "str", "required": False, "nullable": True},
    "address": {"type": "object", "fields": {"landmark": {"type": "str"}}},
}
assert breaking_changes(v1, v3) == [
    "address.pincode: removed",
    "coupon: became nullable",
    "method: enum constraint dropped",
    "order_id: removed",
    "qty: type int -> bool",
], breaking_changes(v1, v3)

# A type change on an object is reported once, without recursing into it.
assert breaking_changes(v1, dict(v1, address={"type": "str"})) == ["address: type object -> str"]

A common wrong solution (the tests catch it):

def breaking_changes(old, new, _prefix=""):
    problems = []
    for name in sorted(old):
        path = f"{_prefix}{name}"
        o, n = old[name], new.get(name)
        if n is None:
            problems.append(f"{path}: removed")
            continue
        if o.get("required", True) and not n.get("required", True):
            problems.append(f"{path}: became optional")
        if n.get("nullable", False) and not o.get("nullable", False):
            problems.append(f"{path}: became nullable")
        if o["type"] != n["type"]:
            problems.append(f"{path}: type {o['type']} -> {n['type']}")
            continue
        if "enum" in o and "enum" in n:
            added = sorted(set(n["enum"]) - set(o["enum"]))
            if added:
                problems.append(f"{path}: enum values added {added}")
        if o["type"] == "object":
            problems += breaking_changes(o["fields"], n["fields"], f"{path}.")
    return problems

Alternatives and tradeoffs:

  • Use the real tooling: Avro / Protobuf schema registries (Confluent, Buf) implement BACKWARD / FORWARD / FULL compatibility modes, and JSON Schema diff tools exist. Writing this yourself is reasonable only for an in-house dict/Pydantic-based contract; the important part is choosing the direction of compatibility.
  • Generate the schema from Pydantic models (Model.model_json_schema()) and diff those, so the contract can't drift from the code that produces events.
  • Contract tests with consumer examples (consumer-driven contracts, Pact-style): each consumer commits example payloads it relies on and CI validates them against the new producer schema. Catches semantic changes the structural diff misses (e.g. amount switching from rupees to paise).

Follow-ups:

  1. "This checks one direction. When do you need the other one too?" If consumers can be upgraded before producers (or both read historical data from the lake), old records must also be readable by the new schema: that's the reverse check, breaking_changes(new, old). "Full" compatibility requires both, which effectively allows only adding/removing optional fields. Pick per topic and write it down in the contract.
  2. "The check passes, but finance's job broke because amount changed from rupees to paise. What now?" Structure is not semantics. Units, currency, time zone and meaning must be part of the contract (field names like amount_paise, descriptions, or a unit annotation), changes to meaning need a new field or a new event version, and consumer-driven contract tests or reconciliation checks (totals vs the ledger) catch what a schema diff can't.
  3. "How would you roll out a breaking change that is genuinely needed?" Expand/contract: publish a new field (or order_placed.v2 topic) alongside the old one, migrate consumers one by one with a tracked list and a deadline, monitor who still reads the old field, then stop producing it. The CI gate should allow an explicit, reviewed override that links to that migration plan.

Scoring: Strong: Defines compatibility as "new values ⊆ old values" before coding, gets int/float/bool and optional-removal right, recurses with paths, and discusses direction (backward vs forward), semantics and rollout. Acceptable: Handles removal, nullability and enums but treats every type change as breaking, or flags harmless optional-field removal; corrects it after discussing the rule. Red flag: Treats "any change" as breaking or "only removals" as breaking without reasoning about which records consumers will see; can't explain why adding an enum value can break a consumer.

SR-H-D01 · Serving a fraud model at checkout, and shipping v8 without breaking payments

Hard · Design · Senior (5–8 years) · model serving, FastAPI, latency budgets, feature stores, shadow and canary releases, fallbacks, observability · 40 min

Scenario:

A payments company scores every UPI and card transaction with a fraud model before authorising it. Today the model (a gradient-boosted tree, ~40 MB, scikit-learn/LightGBM) runs inside the Django monolith: it's loaded at import time in every Gunicorn worker, features are computed with four SQL queries per request, and a deploy of the monolith is the only way to ship a new model.

You own building a separate scoring service in Python. Requirements:

  • Peak 3,000 requests/s (festive sales), normal ~600/s.
  • The checkout path gives fraud scoring a p99 budget of 50 ms end to end, including feature lookup.
  • Data science wants to ship fraud-v8 next month, and new models roughly every two weeks after that.
  • If scoring is unavailable, payments must not stop.

Clarifying questions:

  • What does the caller need back: a score, a decision (allow / step-up OTP / block), reasons? (Score plus decision plus top reason codes; the decision thresholds belong to the risk team and change more often than the model.)
  • Which features are needed, and how fresh? (About 60 features: ~45 are aggregates such as "transactions by this VPA in the last 24 h", 15 come from the request itself. Aggregates may be a few seconds stale.)
  • How is the model's inference cost? (Measured: ~1.5 ms per row single-threaded, ~4 ms for a batch of 32.)
  • What's the fallback policy when scoring times out: allow, allow under an amount, or rules-only? (Risk team: a simple rules engine plus allow under ₹2,000; above that, step-up authentication.)
  • Are there audit or regulatory needs? (Yes: every decision must be explainable later with the exact model version and the feature values used.)

Reference design:

Shape of the service. - A stateless FastAPI service, POST /score, with a Pydantic v2 request model (strict types, amounts in paise, explicit schema_version). Run several Uvicorn worker processes per pod (one per vCPU), because inference is CPU-bound: inside the async def endpoint, run the model call through a small thread pool only if the library releases the GIL (LightGBM's predict does), otherwise call it in a sync def endpoint or use processes. Measure rather than assume. - Load the model at startup (lifespan handler), not at import and not per request; fail readiness until the model and its feature schema are loaded and a smoke prediction succeeds.

Latency budget (p99 50 ms): network + parsing ~5 ms, feature lookup ≤ 15 ms, inference ≤ 10 ms, slack ~20 ms. - Features: the four SQL queries go. A streaming job (Kafka/Flink or a Python consumer) maintains per-VPA and per-device aggregates in Redis (or a managed online feature store). The service does one pipelined MGET/hash read with a 15 ms timeout. Missing features are explicit (None plus an "is_missing" flag the model was trained with), never silently zero. - The same feature code for training and serving: feature definitions live in one versioned Python package used by both the offline pipeline and the online service, to avoid train/serve skew. - Inference: a single-row tree prediction is cheap; micro-batching (collect for ≤ 2 ms or 32 rows) only pays off for heavier models or GPUs. Start without it and add it when the profile says so.

Model lifecycle. - Models are artifacts in a registry (MLflow or a versioned bucket) with metadata: version, feature list and schema, training data window, offline metrics, checksum/signature. The service loads by version pin from config, verifies the checksum, and never unpickles untrusted files (prefer a native/ONNX format). - Shadow first: v8 scores every request asynchronously (off the request path, sampled if needed) and both scores are logged; compare distributions, latency and the decision-change rate by segment (amount band, merchant category) for a week. - Canary: route 1% → 10% → 50% of decisions to v8 by a stable hash of the customer ID (so a user doesn't flip between models), with automatic rollback on latency or decision-rate guardrails. - Thresholds are config owned by the risk team, versioned separately from the model, and logged with each decision.

Failure handling. The caller (payments) enforces a hard 50 ms timeout and applies the fallback policy; the scoring service also degrades internally (feature timeout → score with missing-feature flags; model error → rules engine). A circuit breaker in the caller stops waiting on a dead service.

Observability and audit. Per-request structured log or event: request ID, model version, threshold version, feature vector hash (full features to an audit topic), score, decision, latency breakdown. Metrics: p50/p99 per stage, fallback rate, missing-feature rate per feature, score distribution per model version (drift alarms).

Trade-offs:

  • In-process model in the monolith vs separate service: in-process has no network hop, but couples model releases to monolith deploys and loads the model into every web worker. A service adds ~2–5 ms and an operational surface, and buys independent releases, scaling and observability.
  • Online feature store vs computing features on request: on-request is fresh and simple but blows the latency budget and loads the OLTP database. Precomputed aggregates are fast but slightly stale and need a streaming pipeline to own and monitor.
  • Python service vs a dedicated inference server (Triton, BentoML, KServe): Python/FastAPI is flexible and matches the team's skills; a dedicated server gives batching, multi-model hosting and GPU management. For tree models on CPU, Python is enough.
  • Fail-open vs fail-closed: failing open protects revenue and user experience; failing closed protects against fraud. The amount-based hybrid is a business decision; the engineer's job is to make it explicit, configurable and measured.
  • Shadow cost: running two models doubles inference CPU during the shadow period; sample if needed.

Deep-dive probes:

  • "p99 is 140 ms at 3k RPS but p50 is 9 ms. Walk me through finding the cause." Look for: per-stage timing histograms; checking the Redis client's pool size and timeouts; event-loop lag (a CPU-bound predict inside async def); GC pauses; uneven load across workers; py-spy on a hot pod; tail amplification from fan-out reads.
  • "v8 offline AUC is better, but in canary the block rate for small merchants jumped 3×." Look for: segment-level guardrails, train/serve skew checks (log online features and re-score offline), a missing-feature default that differs between training and serving, and rollback before root cause.
  • "How do you load a new model version without restarting pods?" Look for: load and validate the new model in the background, then swap a single reference atomically; keep the old one until in-flight requests finish; or simply roll pods (simpler, often better) with readiness gates.
  • "Six months later, a customer disputes a block. What can you show?" Look for: an immutable audit record with model version, threshold version and feature values, plus the ability to re-score that record with the archived model.

Rubric: Strong: Builds an explicit latency budget and designs to it (precomputed features, one round trip, startup loading, CPU-aware workers); separates model, thresholds and fallback policy; describes shadow → canary with segment guardrails and rollback; covers train/serve skew, audit and per-stage observability. Acceptable: Proposes a reasonable FastAPI service with startup model loading and a cache for features, and some rollout plan, but leaves the latency budget, fallback policy or skew handling vague until prompted. Red flag: Keeps the four SQL queries on the request path, loads the model per request, ships v8 with a straight cutover, or has no answer for "the scoring service is down".

SR-H-D02 · Sending 2 crore sale notifications in 30 minutes without double-sending or delaying OTPs

Hard · Design · Senior (5–8 years) · job queues, rate limiting, idempotency, priorities, retries, backpressure, observability, compliance · 40 min

Scenario:

An e-commerce company runs a "Big Diwali Sale" that starts at 8:00 PM IST. Marketing wants 2 crore (20 million) push notifications and 50 lakh SMS delivered between 8:00 and 8:30 PM. The same notification platform also sends OTPs and order updates all day, and OTP delivery must stay under 5 s even during the blast.

Today a cron job loops over users in Python and calls the SMS and push providers synchronously. Last year it took 3 hours, some users got the same SMS four times, OTPs were delayed by 10 minutes and the SMS provider throttled the account. You own the redesign. The team is Python (Celery or similar is available, plus Postgres, Redis and Kafka).

Clarifying questions:

  • What are the provider limits? (SMS gateway: 1,000 messages/s per account across two providers; push (FCM/APNs): effectively ~10k/s per sender with batching up to 500 per request.)
  • Is the audience fixed at 7:00 PM, or can it change during the send? (Segment frozen at 7:30 PM; opt-outs must be honoured until the moment of sending.)
  • What are the compliance rules? (TRAI: promotional SMS only to non-DND numbers, during permitted hours, with the registered template; opt-outs honoured immediately.)
  • What does "delivered" mean for marketing: accepted by the provider, or confirmed on the device via delivery receipts? (They report on provider DLRs.)
  • Can a duplicate ever be acceptable? (For marketing, rare duplicates are tolerable but not 4×; for OTPs a duplicate is fine, a delay is not.)

Reference design:

Separate the paths. OTPs/transactional messages and campaigns never share a queue or worker pool. Transactional traffic gets its own high-priority queue, dedicated workers and a reserved slice of each provider's rate limit (e.g. 200 of the 1,000 SMS/s). Campaign traffic can only use what's left.

Campaign pipeline. 1. Plan (7:30 PM): freeze the audience into a campaign_recipients table (campaign_id, user_id, channel, status), filtering DND, opt-outs and invalid tokens up front. Split into shards of ~1,000 recipients; one job per shard, enqueued onto the campaign queue (Celery/RQ/Arq on Redis, or Kafka partitions). 2. Send (8:00 PM): shard workers load their recipients, re-check opt-out at send time (cheap set lookup), render templates, and call providers in batches (push: 500 per FCM call) through an async HTTP client (httpx with asyncio) since this is I/O-bound: one process per core, each with hundreds of concurrent requests bounded by a semaphore. 3. Rate limiting: a distributed token bucket in Redis per provider and channel (atomic Lua script), shared by all workers; workers acquire tokens in chunks to reduce Redis round trips. Respect 429 / throttling responses with backoff, and feed them back into the limiter. 4. Idempotency (the 4× SMS bug): each message has a deterministic ID (campaign_id, user_id, channel). Before sending, the worker claims it with a state transition pending → sending (UPDATE ... WHERE status='pending'); after the provider accepts it, sending → sent with the provider message ID. Retries of a shard skip sent rows. Where the provider supports a client reference / idempotency key, pass the message ID so the provider dedupes too. A crash between the provider call and the sent write can still cause one duplicate: that's at-least-once, made rare and bounded rather than impossible. 5. Retries: retry only retryable failures (timeouts, 5xx, 429) with jittered exponential backoff and a cap; permanent failures (invalid number, unregistered token) mark the recipient and clean the token table. After the campaign window, stop: a sale notification at 11 PM is worse than none.

Throughput math. 50 lakh SMS at ~800/s available ≈ 104 minutes: the SMS goal is impossible with these limits. Say so early: negotiate a temporary limit increase with both providers, prioritise SMS to users without push, or start SMS earlier. Push: 2 crore at ~10k/s ≈ 33 minutes, feasible with batching. The engineer should do this arithmetic before designing workers.

Observability: per campaign and channel, metrics for queued / sending / sent / failed counts, send rate vs the limiter's rate, provider latency and error codes, OTP end-to-end latency (the SLO that matters most), and a live dashboard with a kill switch to pause a campaign instantly (workers check a Redis flag per batch).

Trade-offs:

  • Celery/RQ jobs vs Kafka partitions: a task queue is simple and gives retries and visibility per job; Kafka gives ordered, replayable partitions and higher throughput but you build retry/dead-letter handling. For 20k shard jobs, a task queue is enough.
  • Pre-render vs render at send time: pre-rendering moves work out of the peak but stores 2.5 crore payloads; rendering at send time is cheap with simple templates and allows last-minute fixes.
  • Exactly-once is not available across an external SMS provider. The design aims for at-least-once with a claim step that makes duplicates rare; the alternative (send at most once, mark before sending) loses messages on crash. For marketing, prefer rare duplicates; state it explicitly.
  • Threads vs asyncio for sending: both work for I/O; asyncio scales to more concurrent requests per process with less memory, threads are simpler with blocking SDKs. Avoid mixing a blocking provider SDK into an event loop.

Deep-dive probes:

  • "At 8:05 PM OTP latency climbs to 40 s. What happened and how do you stop it now?" Look for: shared rate limit or shared workers between paths, provider throttling the whole account; immediate action: pause the campaign via the kill switch, then confirm OTP latency recovers; long-term: reserved capacity and separate sender IDs/accounts.
  • "Half the workers crash mid-campaign. What state is everything in, and how do you resume?" Look for: shard jobs are idempotent, sending rows older than N minutes are reconciled against provider DLRs/message IDs before retrying, and resume re-enqueues only unfinished shards.
  • "How do you test this before Diwali?" Look for: a load test against a provider sandbox or a fake provider that enforces the same limits and injects 429s and timeouts; a dry run to an internal segment; chaos tests that kill workers; and asserting no duplicate message IDs.
  • "A user opted out at 8:10 PM but got an SMS at 8:12 PM." Look for: opt-out check at send time from a fast store, propagation delay from the preferences service, and a measurable bound on that delay.

Rubric: Strong: Does the throughput arithmetic and challenges the SMS target; isolates transactional traffic with reserved capacity; designs a shared token bucket, shard jobs and a claim-based idempotency scheme with an honest at-least-once statement; covers retries, compliance, kill switch and OTP SLO monitoring. Acceptable: Moves to a queue with workers and some rate limiting and dedupe, but shares capacity with OTPs or never checks whether the provider limits make the goal feasible. Red flag: "Use more threads" or "use asyncio" as the whole answer; no rate limiting or idempotency; claims exactly-once delivery through an SMS provider.

Hard & Tricky (HT)

SR-HT-O01 · An early return from async for leaves the DB connection checked out

Hard & Tricky · Output · Senior (5–8 years) · asyncio, async generators, resource cleanup, aclosing, connection pools, CPython-specific · 10 min

Question: A FastAPI service streams rows from Postgres through an async generator. Under load, the pool reports connections "in use" that no request is using, and occasionally runs dry. The pool is simulated with a counter. What does this print? (CPython 3.12, standard asyncio event loop.)

import asyncio
from contextlib import aclosing

pool = {"in_use": 0}

async def stream_rows(query):
    pool["in_use"] += 1
    print(f"acquire {query}")
    try:
        for i in range(1, 100):
            await asyncio.sleep(0)      # fetch the next row from the DB
            yield i
    finally:
        pool["in_use"] -= 1
        print(f"release {query}")

def stream_rows_sync(query):
    pool["in_use"] += 1
    print(f"acquire {query}")
    try:
        yield from range(1, 100)
    finally:
        pool["in_use"] -= 1
        print(f"release {query}")

def first_match_sync(query, target):
    for row in stream_rows_sync(query):
        if row == target:
            return row

async def first_match(query, target):
    async for row in stream_rows(query):
        if row == target:
            return row

async def first_match_closing(query, target):
    async with aclosing(stream_rows(query)) as rows:
        async for row in rows:
            if row == target:
                return row

async def main():
    print("found", first_match_sync("q1", 3), "in_use", pool["in_use"])
    print("found", await first_match("q2", 3), "in_use", pool["in_use"])
    await asyncio.sleep(0.01)
    print("after sleep, in_use", pool["in_use"])
    print("found", await first_match_closing("q3", 3), "in_use", pool["in_use"])

asyncio.run(main())

Expected output:

acquire q1
release q1
found 3 in_use 0
acquire q2
found 3 in_use 1
release q2
after sleep, in_use 0
acquire q3
release q3
found 3 in_use 0

The trap: release q2 printed right after acquire q2, before found 3 in_use 0, "just like the sync version, because returning out of the loop closes the generator". That's true for sync generators and false for async ones. The other common miss is the opposite: assuming q2 is never released at all.

Explanation:

Sync generator (q1). When first_match_sync returns, the last reference to the generator disappears; CPython frees it immediately, and freeing a suspended generator calls close(), which raises GeneratorExit at the yield and runs finally synchronously. Release happens before the print arguments are evaluated.

Async generator (q2). Its finally may need to await (a real driver awaits conn.release()), and that can't happen inside a deallocator. So when an unfinished async generator is garbage-collected, CPython calls the event loop's finalizer hook (sys.set_asyncgen_hooks), and asyncio's hook schedules agen.aclose() as a new task for a later loop iteration. first_match returns with the connection still checked out (in_use 1); the release runs only once the loop gets control back, during the sleep.

Under load that "later" stretches: every early exit (a return, a break, an exception, a client disconnect) leaves a connection held until the loop gets round to the cleanup task, and if anything else still references the generator (a traceback, a list, a debugger) it isn't finalized at all until that reference goes. On a busy pool this shows up as phantom in-use connections and pool exhaustion.

contextlib.aclosing (q3) (3.10+) awaits aclose() when the async with block exits, so cleanup is deterministic and happens before first_match_closing returns.

Takeaway: whenever an async generator holds a resource, consume it under async with aclosing(...), or make the resource's owner a real async context manager around the loop.

Follow-ups:

  1. "What happens if the event loop closes before the scheduled aclose() runs?" asyncio.run calls loop.shutdown_asyncgens() before closing, which aclose()s every async generator the loop still tracks, so cleanup happens at shutdown at the latest. With a hand-managed loop that skips this, you get "Task was destroyed but it is pending" or an async generator that is never finalized, and the finally block never runs.
  2. "How does this interact with FastAPI's StreamingResponse when the client disconnects?" The response stops iterating your generator and the generator is closed through the same paths (explicit aclose by the server, or finalization). Put resource ownership in a dependency with yield or an async with inside the generator around the whole stream, and test the disconnect path explicitly.
  3. "Is the sync behaviour (q1) guaranteed by the language?" No: immediate finalization comes from CPython's reference counting. On PyPy a generator abandoned mid-way is closed only when the GC runs. Portable code uses contextlib.closing(gen) or a with block for sync generators that hold resources, too.

Scoring: Strong: Exact order; contrasts refcount-driven close() for sync generators with the finalizer hook scheduling aclose() as a task; prescribes aclosing; links it to pool exhaustion. Acceptable: Knows the async case is different and that aclosing fixes it, but misplaces release q2. Red flag: Assumes finally in a generator always runs immediately on return/break, or proposes a bigger pool as the fix.

SR-HT-O02 · One task's Decimal precision leaks into GST for every request

Hard & Tricky · Output · Senior (5–8 years) · decimal, contextvars, asyncio, money, shared mutable state · 10 min

Question: A pricing service computes GST and FX quotes with Decimal inside asyncio tasks. After a release that added an FX endpoint, a few invoices show amounts like 1.456E+4. The FX author says "I only changed the precision in my own task, and each task gets its own copy of the context". What does this print?

import asyncio
import decimal
from decimal import Decimal

decimal.getcontext()      # app startup touched the context (e.g. a config parser)

async def fx_quote(amount):
    ctx = decimal.getcontext()
    ctx.prec = 4                      # "only this task needs 4 significant digits"
    ctx.rounding = decimal.ROUND_DOWN
    await asyncio.sleep(0)
    return amount * Decimal("83.4567")

async def gst(amount):
    await asyncio.sleep(0)
    return amount * Decimal("1.18")

async def fx_quote_local(amount):
    with decimal.localcontext(prec=4, rounding=decimal.ROUND_DOWN):
        await asyncio.sleep(0)
        return amount * Decimal("83.4567")

async def main():
    print(await asyncio.gather(gst(Decimal("12345.67")), fx_quote_local(Decimal("10"))))
    print(await asyncio.gather(gst(Decimal("12345.67")), fx_quote(Decimal("10"))))
    print(Decimal("12345.67") * Decimal("1.18"), decimal.getcontext().prec)

asyncio.run(main())

Expected output:

[Decimal('14567.8906'), Decimal('834.5')]
[Decimal('1.456E+4'), Decimal('834.5')]
1.456E+4 4

The trap: Line 2 as [Decimal('14567.8906'), Decimal('834.5')] and line 3 as 14567.8906 28, on the reasoning that every task runs in a copy of the context, so changing precision in fx_quote stays inside fx_quote. The copy is real, but it is a shallow copy.

Explanation:

Since Python 3.7 the decimal context lives in a ContextVar, and each asyncio task runs in a copy of the contextvars context taken when the task is created. Two details make that copy useless here:

  1. The copy is shallow. It copies the mapping of variables to values, not the values. If the decimal context object already exists in the parent (any Decimal operation creates it, even building Decimal("12345.67") as an argument; the startup line just makes it explicit), every task's copy points at the same mutable decimal.Context object.
  2. ctx.prec = 4 mutates that shared object; it doesn't .set() a new value in the task's copy. So gst, which runs after fx_quote mutated it (the sleep(0) lets that happen), multiplies at 4 significant digits with ROUND_DOWN: 14567.8906 becomes 1.456E+4. The change also leaks back into main and every later request.

decimal.localcontext(...) creates a new context and sets it in the current task's context var for the duration of the with block, then restores the previous one. That's isolated per task, so the first gather is correct.

The same trap applies to any ContextVar holding a mutable object (a dict of request tags, a list of spans): mutate it in a child task and the parent and siblings see it; var.set(new_obj) stays local.

Takeaway: never mutate the result of decimal.getcontext() in request code; use localcontext(), or pass an explicit context (ctx.multiply(a, b), amount.quantize(Decimal("0.01"), rounding=...)). For money, quantize to paise explicitly rather than relying on precision.

Follow-ups:

  1. "Why does the 28-digit default not matter for typical rupee amounts, and what should define rounding instead?" prec is significant digits for intermediate results, and 28 digits is far more than any invoice needs. What matters is the final rounding rule: quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) (or what the tax rules say) at defined points, such as per line item vs per invoice. That's a business rule to make explicit in code and tests, not a global setting.
  2. "Would the same bug happen with threads instead of asyncio tasks?" No, not in this form. A new thread starts with an empty contextvars context, so its first getcontext() creates a fresh default decimal.Context for that thread, and prec = 4 there affects only that thread. (Thread pools reuse threads, so the change would still leak into later jobs on the same worker thread.) Tasks are the surprising case because they inherit the parent's context object.
  3. "How would you catch this class of bug in CI?" A test that runs the FX endpoint and the GST computation concurrently and asserts exact GST output, plus a lint rule or grep that bans getcontext(). attribute assignment outside startup. Property-based tests over amounts catch precision-dependent outputs that a few hand-picked examples miss.

Scoring: Strong: Exact output; explains the context var holds a shared mutable Context, that tasks copy the mapping shallowly, the difference between mutating and localcontext()/set, and the leak into main. Acceptable: Spots the leak on line 2 but believes main is unaffected, or can't say why the copy doesn't protect it. Red flag: Says Decimal precision is "per task" or "per coroutine", or proposes setting prec back after the await as the fix.

SR-HT-O03 · A retry loop that eats the timeout meant to stop it

Hard & Tricky · Output · Senior (5–8 years) · asyncio, cancellation, CancelledError, asyncio.timeout, retries, BaseException · 10 min

Question: A payout service wraps a bank call in a retry helper and gives the whole operation a 150 ms budget with asyncio.timeout. Incident review: payouts "timed out" in the dashboard but the service kept calling the bank well past the budget, and the handler logged "gave up" instead of a timeout. What does this print? (Each bank call takes 100 ms and then fails.)

import asyncio

attempts = []

async def call_bank(i):
    attempts.append(i)
    await asyncio.sleep(0.1)
    raise ConnectionError("bank 503")

async def with_retries(n):
    for i in range(n):
        try:
            return await call_bank(i)
        except BaseException as exc:          # "never let a retry loop crash"
            print(f"attempt {i}: {type(exc).__name__}")
    return "gave up"

async def main():
    try:
        async with asyncio.timeout(0.15):
            result = await with_retries(3)
        print("result:", result)
    except TimeoutError:
        print("timed out")
    print("attempts:", attempts)
    task = asyncio.current_task()
    print("cancelling:", task.cancelling())

asyncio.run(main())

Expected output:

attempt 0: ConnectionError
attempt 1: CancelledError
attempt 2: ConnectionError
result: gave up
attempts: [0, 1, 2]
cancelling: 0

The trap: attempt 0: ConnectionError, then timed out and attempts: [0, 1]: "the timeout fires at 150 ms, so the second attempt is interrupted and TimeoutError comes out of the async with". That is what happens with except Exception; with except BaseException the timeout is silently defeated.

Explanation:

How asyncio.timeout works: when the deadline passes, it calls task.cancel() on the current task. That raises CancelledError at whatever await the task is suspended on, here attempt 1's sleep (t ≈ 150 ms, 50 ms into the second call). On the way out, __aexit__ checks two things: that the exception leaving the block is CancelledError, and that it isn't a cancellation requested by someone else (it calls task.uncancel() and compares the count). Only then does it convert the cancellation into TimeoutError.

CancelledError derives from BaseException (since 3.8) precisely so that except Exception doesn't swallow it. The retry loop catches BaseException, so it treats the cancellation like any other failed attempt, logs it, and starts attempt 2: another real request to the bank after the budget has expired. When the block finally exits normally with "gave up", __aexit__ sees no exception, so it raises nothing; the deadline has expired, but it has also undone its cancel request (uncancel()), which is why cancelling() is back to 0.

Net effect in production: timeouts don't bound latency, cancelled requests (client disconnects, shutdown) keep hammering a struggling dependency, and graceful shutdown hangs, because the same mechanism also stops TaskGroup and asyncio.run from cancelling the task.

With except Exception the output would be attempt 0: ConnectionError, timed out, attempts: [0, 1].

Takeaway: retry only specific, retryable exceptions; if you must catch broadly, re-raise asyncio.CancelledError first (except asyncio.CancelledError: raise). Never convert cancellation into a normal return.

Follow-ups:

  1. "You need cleanup (e.g. release a lock, record a metric) when the call is cancelled. How do you write it?" Use try/finally, or except asyncio.CancelledError: cleanup(); raise. The cleanup must be fast and must not await anything that can hang; if it has to await, bound it, and still re-raise. Never return from a handler for CancelledError.
  2. "Is it ever legitimate to suppress a CancelledError?" Rarely: when your code itself cancelled a child task and is awaiting it to confirm it stopped (task.cancel(); with suppress(CancelledError): await task). Even then, check asyncio.current_task().cancelling() so you don't swallow a cancellation aimed at you, which is exactly the bookkeeping asyncio.timeout and TaskGroup do with uncancel().
  3. "How would the retry helper respect an overall deadline even without cancellation?" Pass a deadline (loop.time() + budget) down, compute the remaining time before each attempt, skip the retry if the remaining budget is smaller than a minimum useful attempt, and use asyncio.timeout_at(deadline) per attempt. Add jittered backoff, and retry only idempotent operations or ones with an idempotency key.

Scoring: Strong: Exact output including cancelling: 0; explains that timeout cancels the task, needs CancelledError to reach __aexit__, why it's a BaseException, and the production consequences (extra calls, hung shutdown). Acceptable: Recognises the swallowed cancellation and gets most lines, misses the third attempt or the cancelling() value. Red flag: Expects timed out, or recommends except BaseException / bare except in retry loops as defensive coding.

SR-HT-O04 · Vectorised per-merchant counters that undercount

Hard & Tricky · Output · Senior (5–8 years) · numpy, fancy indexing, ufunc.at, bincount, float32, aggregation correctness, performance · 10 min

Question: A fraud feature job replaced a slow Python loop with NumPy to aggregate UPI transactions per merchant in each micro-batch. The numbers are now "a bit low", and a daily counter stored as float32 has stopped moving. What does this print?

import numpy as np

# merchant index of each UPI transaction in one micro-batch
merchant_idx = np.array([0, 2, 2, 1, 2, 0])
amount_inr = np.array([100.0, 250.0, 250.0, 75.0, 40.0, 10.0])

txn_count = np.zeros(3, dtype=np.int64)
txn_count[merchant_idx] += 1
volume = np.zeros(3)
volume[merchant_idx] += amount_inr
print(txn_count, volume)

txn_count = np.zeros(3, dtype=np.int64)
np.add.at(txn_count, merchant_idx, 1)
print(txn_count, np.bincount(merchant_idx, weights=amount_inr, minlength=3))

daily = np.zeros(1, dtype=np.float32)
daily[0] = 16_777_216            # 2**24 transactions so far today
for _ in range(1000):
    daily[0] += 1
print(daily[0], np.float32(16_777_216) + np.float32(1) == np.float32(16_777_216))

Expected output:

[1 1 1] [10. 75. 40.]
[2 1 3] [110.  75. 540.]
1.6777216e+07 True

The trap: [2 1 3] [110. 75. 540.] on the first line, because a[idx] += v "obviously" adds once per index the way the Python loop did. And 16778216.0 on the last line, because adding 1 a thousand times to a float must add 1000.

Explanation:

Mechanism 1: augmented assignment with fancy indexing is buffered, not accumulated. a[idx] += v is executed as a.__setitem__(idx, a.__getitem__(idx) + v): gather all indexed values into a temporary, add, then scatter back. With duplicate indices, each duplicate writes into the same slot, so only one of the additions survives. Merchant 2 appears three times: every copy computes 0 + 1 (count) or 0 + amount, and in practice the last one written wins, 40.0; merchant 0 gets 10.0, not 110.0. (NumPy's docs say which duplicate wins is not guaranteed; either way, the sum is lost.) There is no error and no warning; the result just looks plausible.

The fixes are unbuffered operations: np.add.at(a, idx, v) applies the add once per index occurrence (and has been much faster since NumPy 1.25), and np.bincount(idx, weights=..., minlength=n) is the fastest way to build per-group counts and sums for integer keys. pandas.groupby().sum() is the other usual choice.

Mechanism 2: float32 has a 24-bit significand. Above 2²⁴ = 16,777,216 the gap between adjacent float32 values is 2, so 16777216 + 1 rounds back to 16777216 (round-half-to-even) every time: the counter freezes silently. Big totals in float32 lose paise long before that, too.

Takeaway: test vectorised rewrites against the slow loop on data with duplicate keys; use np.add.at, bincount or groupby for scatter-adds; keep counters in int64 and money in integer paise or Decimal, never float32.

Follow-ups:

  1. "How would you have caught this before release?" Differential testing: keep the old loop as an oracle and compare on random batches (seeded, with heavy key repetition) using hypothesis or a fixed generator, plus a reconciliation check in production: the sum of per-merchant counts must equal the batch length, and the sum of volumes must equal amount_inr.sum().
  2. "Which is faster here, np.add.at or np.bincount, and when can't you use bincount?" bincount is typically faster: it is a single specialised pass. It only works for non-negative integer keys and sums (or counts); for other ufuncs (np.maximum.at, np.minimum.at), multi-dimensional targets, or negative keys you need ufunc.at or a groupby. Measure with realistic sizes before choosing.
  3. "The counter has to be float for a downstream library. What then?" Accumulate in int64 (or float64, which is exact for integers up to 2⁵³) and convert at the boundary. If you must sum many floats, use math.fsum or NumPy's pairwise sum in float64, never a running float32 total.

Scoring: Strong: All three lines exactly; explains gather-add-scatter and why duplicates collapse to one write, names np.add.at/bincount, explains the 2²⁴ significand limit, and proposes a reconciliation invariant. Acceptable: Knows duplicate indices are a problem with += but can't predict the exact values, or misses the float32 freeze. Red flag: Assumes the vectorised version is equivalent to the loop by construction and would ship it without a comparison test.

SR-HT-C01 · Dynamic micro-batching for a GPU model behind an async API

Hard & Tricky · Coding · Senior (5–8 years) · asyncio, futures, cancellation, model serving, batching, latency vs throughput · 30 min

Problem:

A FastAPI service scores transactions with a fraud model on a GPU. One item per forward pass wastes the GPU; a batch of 32 costs almost the same as a batch of 1. Requests arrive one at a time, so you'll build a micro-batcher:

  • await batcher.submit(item) returns that item's prediction.
  • A batch is dispatched when it reaches max_batch items, or max_wait seconds after the first item of the batch arrived, whichever comes first. Dispatch calls await predict_batch(items), which returns a list of results in the same order.
  • Each caller receives its own result. If predict_batch raises, every caller in that batch gets the exception; later batches are unaffected. If it returns the wrong number of results, every caller in the batch gets a ValueError (never route results by position when the lengths disagree).
  • Cancellation (client disconnects, request timeouts) must be safe:
  • a caller cancelled before its batch is dispatched is dropped from the batch (don't spend GPU time on it);
  • a caller cancelled while its batch is running must not affect the other callers in that batch.
  • await batcher.close(): stop accepting new items (submit raises RuntimeError), dispatch whatever is queued, and wait for in-flight batches to finish.

Signature: class MicroBatcher(Generic[X, Y]): def __init__(self, predict_batch: Callable[[list[X]], Awaitable[list[Y]]], max_batch: int, max_wait: float) -> None: ... async def submit(self, item: X) -> Y: ... async def close(self) -> None: ...

Constraints:

  • Single event loop; everything runs on it (no threads). predict_batch may take tens of milliseconds and several batches may be in flight at once.
  • Standard library only.

Examples:

  • max_batch=3: three concurrent submit() calls → one predict_batch call with all three, results routed back in order
  • max_batch=100, max_wait=0.01: two submit() calls → one predict_batch call ~10 ms after the first item
  • max_batch=3, the first caller is cancelled while the batch runs → callers 2 and 3 still get their results

Hints:

  1. Give each caller an asyncio.Future and have submit simply await it; the batch task resolves the futures.
  2. loop.call_later gives you the max-wait timer; cancel it when the batch fills up first.
  3. When the awaiting task is cancelled, the future it awaits is cancelled too. What does set_result on a cancelled future do?
  4. Run each batch in its own task, and keep a strong reference to it (the event loop only keeps weak references).
  5. Write the cancellation tests first; they are the part most implementations get wrong.

Reference solution:

import asyncio
from collections.abc import Awaitable, Callable
from typing import Generic, TypeVar

X = TypeVar("X")
Y = TypeVar("Y")


class MicroBatcher(Generic[X, Y]):
    """Group concurrent single-item requests into batched model calls.

    A batch is dispatched when it reaches `max_batch` items, or `max_wait` seconds after
    its first item arrived, whichever comes first. Each caller gets its own result, or the
    batch's exception. A caller that is cancelled must not affect anyone else.
    """

    def __init__(self, predict_batch: Callable[[list[X]], Awaitable[list[Y]]],
                 max_batch: int, max_wait: float) -> None:
        if max_batch < 1 or max_wait < 0:
            raise ValueError("max_batch must be >= 1 and max_wait >= 0")
        self._predict = predict_batch
        self._max_batch = max_batch
        self._max_wait = max_wait
        self._pending: list[tuple[X, asyncio.Future[Y]]] = []
        self._timer: asyncio.TimerHandle | None = None
        self._inflight: set[asyncio.Task[None]] = set()
        self._closed = False

    async def submit(self, item: X) -> Y:
        if self._closed:
            raise RuntimeError("batcher is closed")
        loop = asyncio.get_running_loop()
        fut: asyncio.Future[Y] = loop.create_future()
        self._pending.append((item, fut))
        if len(self._pending) >= self._max_batch:
            self._flush()
        elif self._timer is None:
            self._timer = loop.call_later(self._max_wait, self._flush)
        return await fut              # cancelling the caller cancels `fut`

    def _flush(self) -> None:
        if self._timer is not None:
            self._timer.cancel()
            self._timer = None
        # Drop callers that gave up before dispatch: no wasted GPU time on them.
        batch = [(item, fut) for item, fut in self._pending if not fut.done()]
        self._pending = []
        if not batch:
            return
        task = asyncio.get_running_loop().create_task(self._run(batch))
        self._inflight.add(task)              # keep a strong reference until it finishes
        task.add_done_callback(self._inflight.discard)

    async def _run(self, batch: list[tuple[X, asyncio.Future[Y]]]) -> None:
        items = [item for item, _ in batch]
        try:
            results = await self._predict(items)
            if len(results) != len(items):
                raise ValueError(f"predict_batch returned {len(results)} results for {len(items)} items")
        except Exception as exc:
            for _, fut in batch:
                if not fut.done():
                    fut.set_exception(exc)
            return
        for (_, fut), result in zip(batch, results):
            if not fut.done():                # a caller may have been cancelled mid-flight
                fut.set_result(result)

    async def close(self) -> None:
        """Stop accepting work, dispatch what is queued, and wait for in-flight batches."""
        self._closed = True
        self._flush()
        if self._inflight:
            await asyncio.gather(*self._inflight)

Complexity: O(1) amortised per submit plus O(b) per dispatched batch of size b. Memory: O(queued + in-flight items). Added latency per request is at most max_wait plus the batch's model time.

Tests:

import asyncio

calls = []

async def score(items):
    calls.append(list(items))
    await asyncio.sleep(0.02)                   # simulated GPU forward pass
    if "boom" in items:
        raise RuntimeError("CUDA out of memory")
    return [f"{x}:{len(x)}" for x in items]

async def bad_length(items):
    return ["only-one"]

async def main():
    # 1. Size-triggered dispatch, results routed back in order.
    b = MicroBatcher(score, max_batch=3, max_wait=10)
    out = await asyncio.wait_for(asyncio.gather(*(b.submit(x) for x in ["ab", "c", "def"])), 1)
    assert out == ["ab:2", "c:1", "def:3"] and calls == [["ab", "c", "def"]]

    # 2. Time-triggered dispatch for a partial batch.
    calls.clear()
    b = MicroBatcher(score, max_batch=100, max_wait=0.01)
    out = await asyncio.wait_for(asyncio.gather(b.submit("x"), b.submit("yy")), 1)
    assert out == ["x:1", "yy:2"] and calls == [["x", "yy"]]

    # 3. A failing batch fails each of its callers; the next batch is unaffected.
    calls.clear()
    b = MicroBatcher(score, max_batch=2, max_wait=10)
    res = await asyncio.wait_for(asyncio.gather(b.submit("ok"), b.submit("boom"), return_exceptions=True), 1)
    assert [type(r).__name__ for r in res] == ["RuntimeError", "RuntimeError"]
    assert await asyncio.wait_for(asyncio.gather(b.submit("p"), b.submit("q")), 1) == ["p:1", "q:1"]

    # 4. A caller cancelled BEFORE dispatch is dropped from the batch.
    calls.clear()
    b = MicroBatcher(score, max_batch=10, max_wait=0.03)
    tasks = [asyncio.create_task(b.submit(x)) for x in ["a", "bb", "ccc"]]
    await asyncio.sleep(0)
    tasks[1].cancel()
    res = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 1)
    assert res[0] == "a:1" and res[2] == "ccc:3"
    assert isinstance(res[1], asyncio.CancelledError)
    assert calls == [["a", "ccc"]]

    # 5. A caller cancelled WHILE its batch is running doesn't break the others.
    calls.clear()
    b = MicroBatcher(score, max_batch=3, max_wait=10)
    tasks = [asyncio.create_task(b.submit(x)) for x in ["first", "second", "third"]]
    await asyncio.sleep(0.005)                  # batch dispatched, model still running
    assert calls == [["first", "second", "third"]]
    tasks[0].cancel()
    res = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 1)
    assert isinstance(res[0], asyncio.CancelledError)
    assert res[1:] == ["second:6", "third:5"]

    # 6. A result-count mismatch is an error for everyone, not a silent misroute.
    b = MicroBatcher(bad_length, max_batch=2, max_wait=10)
    res = await asyncio.wait_for(asyncio.gather(b.submit("u"), b.submit("v"), return_exceptions=True), 1)
    assert all(isinstance(r, ValueError) for r in res)

    # 7. close() flushes queued work and refuses new work.
    calls.clear()
    b = MicroBatcher(score, max_batch=10, max_wait=10)
    t = asyncio.create_task(b.submit("late"))
    await asyncio.sleep(0)
    await asyncio.wait_for(b.close(), 1)
    assert await t == "late:4"
    try:
        await b.submit("after-close")
        raise AssertionError("submit after close must fail")
    except RuntimeError:
        pass

asyncio.run(main())

A common wrong solution (the tests catch it):

import asyncio


class MicroBatcher:
    def __init__(self, predict_batch, max_batch, max_wait):
        self._predict = predict_batch
        self._max_batch = max_batch
        self._max_wait = max_wait
        self._pending = []
        self._timer = None
        self._inflight = set()
        self._closed = False

    async def submit(self, item):
        if self._closed:
            raise RuntimeError("batcher is closed")
        loop = asyncio.get_running_loop()
        fut = loop.create_future()
        self._pending.append((item, fut))
        if len(self._pending) >= self._max_batch:
            self._flush()
        elif self._timer is None:
            self._timer = loop.call_later(self._max_wait, self._flush)
        return await fut

    def _flush(self):
        if self._timer is not None:
            self._timer.cancel()
            self._timer = None
        batch, self._pending = self._pending, []
        if not batch:
            return
        task = asyncio.get_running_loop().create_task(self._run(batch))
        self._inflight.add(task)
        task.add_done_callback(self._inflight.discard)

    async def _run(self, batch):
        items = [item for item, _ in batch]
        try:
            results = await self._predict(items)
            if len(results) != len(items):
                raise ValueError("length mismatch")
        except Exception as exc:
            for _, fut in batch:
                fut.set_exception(exc)
            return
        for (_, fut), result in zip(batch, results):
            fut.set_result(result)

    async def close(self):
        self._closed = True
        self._flush()
        if self._inflight:
            await asyncio.gather(*self._inflight)

Alternatives and tradeoffs:

  • A single consumer task reading from an asyncio.Queue: pull the first item, then keep pulling with asyncio.wait_for(queue.get(), remaining) until full or timed out. Equivalent behaviour; one long-lived task to supervise and restart, instead of timer callbacks.
  • Bound concurrency of in-flight batches with a semaphore (one or two per GPU) and apply backpressure to submit (reject with 503 when the queue is too deep) instead of letting latency grow without limit.
  • Serving frameworks (Triton dynamic batching, Ray Serve @serve.batch, TorchServe) implement this with per-model configs. Building it yourself makes sense for a small service; the hard parts are the same either way: cancellation, error isolation and latency SLOs.
  • CPU-bound models: predict_batch must not block the loop. Run it with asyncio.to_thread or in a process pool, otherwise the timer and every other request stall while the model runs.

Follow-ups:

  1. "One poisoned input crashes the whole batch every time. How do you stop it taking 31 other requests down?" On a batch failure, bisect: retry the two halves separately (or fall back to batch size 1) so only the offending item fails, and record the failing input for debugging. Validate inputs (shape, NaN, length) before batching so most poison never reaches the model.
  2. "How do you choose max_batch and max_wait?" From the latency budget and a load test. max_wait adds directly to p50 latency under low traffic, so keep it a small fraction of the SLO (e.g. 2–5 ms of a 50 ms budget). max_batch comes from GPU memory and the throughput curve: increase it until throughput stops improving or p99 breaks the SLO. Export batch size and queue wait as histograms to tune it in production.
  3. "The model is updated (fraud-v7 → v8) while requests are queued. What could go wrong?" A batch could mix items scored by different versions, or a hot swap could happen mid-batch. Bind the model version when a batch is dispatched, return it with each result (for audit and debugging), and swap models by creating a new batcher and draining the old one with close().

Scoring: Strong: Future-per-caller design with size and time triggers, filters cancelled futures before dispatch, guards set_result/set_exception with done(), keeps task references, validates result length; explains the latency/throughput trade-off and how to tune and observe it. Acceptable: Batching by size and time works and errors propagate, but needs a hint to handle cancelled callers (InvalidStateError) or to keep a reference to batch tasks. Red flag: Calls the model once per request inside submit, blocks the loop with a synchronous model call, or can't explain what happens to other callers when one is cancelled.

SR-HT-C02 · Incremental sync that doesn't lose late-committed rows

Hard & Tricky · Coding · Senior (5–8 years) · data pipelines, incremental loads, watermarks, idempotency, late data, backfills · 25 min

Problem:

Every 15 minutes a job copies changed rows from the orders table (OLTP) into the analytics store. It keeps a watermark: the largest updated_at it has seen. Finance found orders that exist in OLTP but never reached analytics, and nobody can reproduce it.

The cause: a row's updated_at is set when its transaction starts, but the row becomes visible only when the transaction commits. A transaction that starts at t=105 and commits after the job's run at t=105 is invisible to that run, and later runs that read "rows newer than the watermark" skip it forever.

Implement one run of the job: sync(source, target, state, lookback=0):

  • source: the rows visible right now ({"id", "version", "updated_at", ...}); target: dict id → row; state["watermark"]: None before the first run.
  • Read rows with updated_at >= watermark - lookback (all rows on the first run). The comparison is inclusive: rows that share the watermark's timestamp must not be skipped. lookback (seconds) re-reads a window before the watermark to catch commits that land late with an older timestamp.
  • Apply a row only if its version is newer than the version already in target (or the id is new), so re-reads, overlapping windows, reruns and replays of old data are no-ops. Store a copy of the row.
  • New watermark = max updated_at among the rows read, but never move it backwards; unchanged if nothing was read.
  • Return the number of rows applied (inserted or updated).

Signature: def sync(source: list[Row], target: dict[str, Row], state: SyncState, lookback: int = 0) -> int:

Constraints:

  • updated_at is an integer number of seconds; version increases by 1 on every update of a row.
  • The same id may appear more than once in one window (several updates between runs), in any order.
  • lookback >= 0; raise ValueError otherwise.

Examples:

  • watermark 105; a row with updated_at=105 commits after the last run → the next run applies it (>= not >)
  • watermark 105; late row with updated_at=101; lookback=10 → applied; watermark stays 105
  • the same run executed twice → second run returns 0 and changes nothing

Hints:

  1. Draw a timeline of two transactions and two job runs. Which rows can each run see?
  2. > vs >= on the watermark: which one loses rows, and which one re-reads rows? Which of those is safe?
  3. Re-reading is only safe if applying a row twice is a no-op. What makes it a no-op?
  4. What should happen to the watermark when the only rows read are old ones from the lookback window?
  5. If one window contains versions 2 and 3 of the same id, in either order, version 3 must win.

Reference solution:

from typing import Any, TypedDict

Row = dict[str, Any]          # {"id": str, "version": int, "updated_at": int, ...}


class SyncState(TypedDict):
    watermark: int | None


def sync(source: list[Row], target: dict[str, Row], state: SyncState, lookback: int = 0) -> int:
    """Copy changed rows from `source` into `target`; return how many rows were applied.

    - Reads rows with updated_at >= watermark - lookback (inclusive: rows that share the
      watermark's timestamp but committed after the last run must not be skipped).
    - Applies a row only if its version is newer than what target holds, so replays,
      overlapping windows and backfills from old snapshots are no-ops.
    - Advances the watermark to the max updated_at seen, and never moves it backwards.
    """
    if lookback < 0:
        raise ValueError("lookback must be >= 0")
    wm = state["watermark"]
    since = None if wm is None else wm - lookback
    changed = [r for r in source if since is None or r["updated_at"] >= since]

    applied = 0
    # Lowest version first, so if the window holds two versions of an id, the newest wins.
    for row in sorted(changed, key=lambda r: (r["id"], r["version"])):
        current = target.get(row["id"])
        if current is None or row["version"] > current["version"]:
            target[row["id"]] = dict(row)     # copy: target must not alias source rows
            applied += 1

    if changed:
        newest = max(r["updated_at"] for r in changed)
        state["watermark"] = newest if wm is None else max(wm, newest)
    return applied

Complexity: O(n log n) per run for n rows in the window (the sort); O(n) extra memory for the window.

Tests:

target = {}
state = {"watermark": None}
source = [
    {"id": "ORD-1", "version": 1, "updated_at": 100, "status": "PLACED"},
    {"id": "ORD-2", "version": 1, "updated_at": 105, "status": "PLACED"},
]

# Initial load.
assert sync(source, target, state) == 2
assert state["watermark"] == 105

# Rerunning the same job is a no-op (idempotent).
assert sync(source, target, state) == 0
assert state["watermark"] == 105

# A row whose transaction began at t=105 commits after the run: same timestamp as the watermark.
source.append({"id": "ORD-3", "version": 1, "updated_at": 105, "status": "PLACED"})
assert sync(source, target, state) == 1
assert "ORD-3" in target

# Late commit with an OLDER timestamp: only a lookback window catches it.
source.append({"id": "ORD-4", "version": 1, "updated_at": 101, "status": "PLACED"})
assert sync(source, target, state, lookback=0) == 0
assert sync(source, target, state, lookback=10) == 1
assert state["watermark"] == 105                      # lookback must not drag the watermark back

# Update to an existing row; the source row object is not aliased into target.
upd = {"id": "ORD-1", "version": 2, "updated_at": 120, "status": "SHIPPED"}
source.append(upd)
assert sync(source, target, state, lookback=10) == 1
assert target["ORD-1"]["status"] == "SHIPPED" and state["watermark"] == 120
upd["status"] = "MUTATED-IN-SOURCE"
assert target["ORD-1"]["status"] == "SHIPPED"

# Backfill from an old snapshot replays stale versions with NEW timestamps: must not regress.
stale_backfill = [{"id": "ORD-1", "version": 1, "updated_at": 130, "status": "PLACED"}]
assert sync(stale_backfill, target, state) == 0
assert target["ORD-1"]["status"] == "SHIPPED"
assert state["watermark"] == 130

# Two versions of one id in a single window: the newest wins regardless of source order.
burst = [
    {"id": "ORD-5", "version": 3, "updated_at": 140, "status": "DELIVERED"},
    {"id": "ORD-5", "version": 2, "updated_at": 135, "status": "SHIPPED"},
]
sync(burst, target, state)
assert target["ORD-5"]["status"] == "DELIVERED" and target["ORD-5"]["version"] == 3

# Empty window leaves the watermark alone.
assert sync([], target, state) == 0 and state["watermark"] == 140

A common wrong solution (the tests catch it):

def sync(source, target, state, lookback=0):
    wm = state["watermark"]
    since = None if wm is None else wm - lookback
    changed = [r for r in source if since is None or r["updated_at"] > since]
    applied = 0
    for row in sorted(changed, key=lambda r: (r["id"], r["version"])):
        current = target.get(row["id"])
        if current is None or row["version"] > current["version"]:
            target[row["id"]] = dict(row)
            applied += 1
    if changed:
        newest = max(r["updated_at"] for r in changed)
        state["watermark"] = newest if wm is None else max(wm, newest)
    return applied

Alternatives and tradeoffs:

  • Change data capture (CDC) from the database log (Debezium on the Postgres WAL / MySQL binlog) orders changes by commit, so there is no late-commit gap at all. It's the robust fix for high-value tables; watermark polling with lookback is the pragmatic fix when CDC isn't available.
  • Watermark on a commit-ordered column: a sequence assigned at commit (e.g. a trigger-maintained change log, or Postgres xmin with care about wraparound) instead of updated_at from the application clock.
  • Upper bound on the window: only read rows with updated_at <= now - safety_lag, so in-flight transactions have time to commit; combine with a lookback for the stragglers.
  • Upsert in the warehouse: MERGE ... WHEN MATCHED AND src.version > tgt.version gives the same idempotency in SQL; the logic here is what that statement must express.

Follow-ups:

  1. "How big should lookback be, and how would you know it's big enough?" At least the longest transaction duration plus clock skew between app servers and the DB, with margin (for example the p99.9 transaction time × 2). Verify it continuously: a daily reconciliation job that compares counts and checksums per day between OLTP and analytics, and alert on any row whose updated_at is older than watermark - lookback when it first appears (that's a row the lookback would have missed).
  2. "Rows can also be hard-deleted in OLTP. What does this design miss, and how do you handle it?" A hard delete leaves nothing to read, so the target keeps the row forever. Options: soft deletes (a deleted_at column, which flows through the same versioned upsert as a tombstone), CDC which emits delete events, or a periodic anti-join reconciliation of ids. Keep tombstones (id + version) in the target so an older replay can't resurrect a deleted row.
  3. "Where should the watermark be stored, and when is it updated?" In durable state (a table in the target or a checkpoint store) updated in the same transaction as the upserts, or strictly after they succeed. Updating it first and then crashing skips data; updating it after the upserts and crashing re-reads data, which is safe because applying is idempotent.

Scoring: Strong: Explains the start-vs-commit visibility gap before coding; uses >= plus lookback; makes application idempotent with a version guard; keeps the watermark monotonic; mentions CDC, reconciliation and where the watermark is persisted. Acceptable: Writes a correct version-guarded upsert and monotonic watermark, but uses > or no lookback until the tie and late-commit cases are pointed out. Red flag: Sets the watermark to the job's wall-clock start time, overwrites rows without a version check, or believes that "it runs every 15 minutes" means no rows can be missed.

SR-HT-C03 · Cache keys that are stable across processes and mean what the call means

Hard & Tricky · Coding · Senior (5–8 years) · caching, inspect.signature, canonical serialisation, hashing, determinism, API design · 30 min

Problem:

Your team is moving a @cached decorator from an in-process dict to Redis, shared by 30 Gunicorn workers on 8 hosts. The old decorator used hash((args, tuple(kwargs.items()))) as the key. In Redis the hit rate is close to zero, and after a price change in Bengaluru, invalidation missed some entries and stale prices were served for hours.

Write cache_key(fn, version, *args, **kwargs) -> str that returns a hex SHA-256 key such that:

  • Stable across processes and restarts: no hash(), no id(), no dependence on set or dict iteration order.
  • Same meaning, same key: positional vs keyword arguments, and omitted defaults vs explicitly passed defaults, give the same key (f("A1"), f("A1", 1), f(sku="A1", qty=1) when qty=1 is the default).
  • Different meaning, different key: 1, 1.0, True and "1" are all different keys (Python considers the first three equal, which is how the old cache mixed them up). Lists and tuples with the same items are the same; order inside a list matters; dict key order and set order don't.
  • Equal values share a key: Decimal("1.50") and Decimal("1.5"); aware datetimes that are the same instant in different time zones (10:00 IST == 04:30 UTC). A date is not the same as its ISO string.
  • Reject what can't be keyed safely with TypeError: unknown types (arbitrary objects), NaN, naive datetimes. A call that fn itself would reject (too many arguments) also raises TypeError.
  • The key includes the function's module and qualified name, and version (bumped when the function's logic changes).

Signature: def cache_key(fn: Callable[..., Any], version: str, /, *args: Any, **kwargs: Any) -> str:

Constraints:

  • Supported value types: None, bool, int, float, Decimal, str, date, aware datetime, and list, tuple, set, frozenset, dict nested arbitrarily of those.
  • Standard library only. The key must be a 64-character lowercase hex string.

Examples:

  • cache_key(price_quote, 'v1', 'A1', 2) vs cache_key(price_quote, 'v1', sku='A1', qty=2) → equal
  • cache_key(price_quote, 'v1', 'A1', 1) vs cache_key(price_quote, 'v1', 'A1', True) → different
  • cache_key(price_quote, 'v1', 'A1', object()) → TypeError

Hints:

  1. inspect.signature(fn).bind(*args, **kwargs) followed by apply_defaults() normalises how arguments were passed.
  2. Turn every value into a canonical JSON-able structure first, then hash json.dumps(..., sort_keys=True).
  3. Tag each value with its type, so 1, 1.0 and True can't collide. Check bool before int and datetime before date: why?
  4. Sets and dict keys can mix types, so sorted() on raw values may raise. Sort by the canonical encoding instead.
  5. How will you prove stability across processes in a test? String hashing changes with PYTHONHASHSEED.

Reference solution:

import datetime as dt
import hashlib
import inspect
import json
from collections.abc import Callable
from decimal import Decimal
from typing import Any


def _canon(value: Any) -> Any:
    """Map a value to a JSON-able structure that is equal iff the values should share a key.

    Every scalar is tagged with its type, so 1, 1.0, True and "1" never collide.
    """
    if value is None:
        return ["none"]
    if isinstance(value, bool):                    # before int: bool is a subclass of int
        return ["bool", value]
    if isinstance(value, int):
        return ["int", str(value)]                 # str: JSON consumers may lose big ints
    if isinstance(value, float):
        if value != value:
            raise TypeError("NaN is not a usable cache key")
        return ["float", repr(value)]
    if isinstance(value, Decimal):
        return ["decimal", str(value.normalize())]  # Decimal("1.50") and Decimal("1.5") are equal
    if isinstance(value, str):
        return ["str", value]
    if isinstance(value, dt.datetime):             # before date: datetime is a subclass of date
        if value.tzinfo is None:
            raise TypeError("naive datetime is ambiguous across servers; pass an aware one")
        return ["datetime", value.astimezone(dt.timezone.utc).isoformat()]
    if isinstance(value, dt.date):
        return ["date", value.isoformat()]
    if isinstance(value, (list, tuple)):
        return ["seq", [_canon(v) for v in value]]
    if isinstance(value, (set, frozenset)):
        items = [_canon(v) for v in value]
        return ["set", sorted(items, key=lambda c: json.dumps(c, sort_keys=True))]
    if isinstance(value, dict):
        pairs = [[_canon(k), _canon(v)] for k, v in value.items()]
        return ["map", sorted(pairs, key=lambda p: json.dumps(p[0], sort_keys=True))]
    raise TypeError(f"unsupported cache key type: {type(value).__name__}")


def cache_key(fn: Callable[..., Any], version: str, /, *args: Any, **kwargs: Any) -> str:
    """Stable key for fn(*args, **kwargs): the same across processes, hosts and restarts.

    Calls that mean the same thing (positional vs keyword, defaults omitted or passed)
    get the same key. Bump `version` when fn's logic changes, to orphan old entries.
    """
    bound = inspect.signature(fn).bind(*args, **kwargs)   # TypeError on a bad call, like fn would
    bound.apply_defaults()
    payload = {
        "fn": f"{fn.__module__}.{fn.__qualname__}",
        "v": version,
        "args": [[name, _canon(val)] for name, val in bound.arguments.items()],
    }
    blob = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()

Complexity: O(n log n) in the total number of values n (sorting set elements and dict keys at each level), plus O(size) for serialising and hashing.

Tests:

import datetime as dt
import os
import subprocess
import sys
from decimal import Decimal

IST = dt.timezone(dt.timedelta(hours=5, minutes=30))

def price_quote(sku, qty=1, *, city="Bengaluru", filters=None):
    return None

SAMPLE = {"filters": {"tags": {"veg", "dairy", "paneer", 7}, "brand": "Amul"}}
if os.environ.get("CACHE_KEY_PROBE"):          # child process: print the key and stop
    print(cache_key(price_quote, "v1", "A1", 2, **SAMPLE))
    sys.exit(0)

def other(sku, qty=1, *, city="Bengaluru", filters=None):
    return None

k = cache_key(price_quote, "v1", "A1", 2)
assert len(k) == 64 and int(k, 16) >= 0

# Same meaning, same key: positional vs keyword, defaults omitted vs explicit.
assert cache_key(price_quote, "v1", "A1", 2) == cache_key(price_quote, "v1", sku="A1", qty=2)
assert cache_key(price_quote, "v1", "A1") == cache_key(price_quote, "v1", "A1", 1, city="Bengaluru")
assert cache_key(price_quote, "v1", "A1", 2, city="Pune") != k

# Order of dict keys and set elements doesn't matter; mixed-type sets don't crash.
f1 = {"brand": "Amul", "tags": {"dairy", "veg", 7}}
f2 = {"tags": {7, "veg", "dairy"}, "brand": "Amul"}
assert cache_key(price_quote, "v1", "A1", filters=f1) == cache_key(price_quote, "v1", "A1", filters=f2)

# Values that compare equal in Python but mean different things get different keys.
assert cache_key(price_quote, "v1", "A1", 1) != cache_key(price_quote, "v1", "A1", True)
assert cache_key(price_quote, "v1", "A1", 1) != cache_key(price_quote, "v1", "A1", 1.0)
assert cache_key(price_quote, "v1", "A1", 1) != cache_key(price_quote, "v1", "A1", "1")
assert cache_key(price_quote, "v1", "A1", [1, 2]) == cache_key(price_quote, "v1", "A1", (1, 2))
assert cache_key(price_quote, "v1", "A1", [1, 2]) != cache_key(price_quote, "v1", "A1", [2, 1])

# Equal amounts and equal instants share a key.
assert cache_key(price_quote, "v1", "A1", Decimal("1.50")) == cache_key(price_quote, "v1", "A1", Decimal("1.5"))
t_ist = dt.datetime(2026, 10, 4, 10, 0, tzinfo=IST)
t_utc = dt.datetime(2026, 10, 4, 4, 30, tzinfo=dt.timezone.utc)
assert cache_key(price_quote, "v1", "A1", t_ist) == cache_key(price_quote, "v1", "A1", t_utc)
assert cache_key(price_quote, "v1", "A1", dt.date(2026, 10, 4)) != cache_key(price_quote, "v1", "A1", "2026-10-04")

# Function identity and version are part of the key.
assert cache_key(other, "v1", "A1", 2) != k
assert cache_key(price_quote, "v2", "A1", 2) != k

# Stable across processes: string hashing (and so set order) differs per hash seed.
here = cache_key(price_quote, "v1", "A1", 2, **SAMPLE)
for seed in ("1", "2", "3"):
    out = subprocess.run([sys.executable, __file__], env={"CACHE_KEY_PROBE": "1", "PYTHONHASHSEED": seed},
                         capture_output=True, text=True, check=True).stdout.strip()
    assert out == here, (seed, out, here)

# Reject what can't be keyed safely, and calls fn itself would reject.
for bad in (object(), float("nan"), dt.datetime(2026, 10, 4, 10, 0)):
    try:
        cache_key(price_quote, "v1", "A1", bad)
        raise AssertionError(f"{bad!r} should be rejected")
    except TypeError:
        pass
try:
    cache_key(price_quote, "v1", "A1", 2, 3)
    raise AssertionError("too many positional args must raise")
except TypeError:
    pass

A common wrong solution (the tests catch it):

import hashlib
import json


def cache_key(fn, version, /, *args, **kwargs):
    payload = {"fn": f"{fn.__module__}.{fn.__qualname__}", "v": version,
               "args": [repr(a) for a in args],
               "kwargs": {k: repr(v) for k, v in kwargs.items()}}
    blob = json.dumps(payload, sort_keys=True)
    return hashlib.sha256(blob.encode()).hexdigest()

Alternatives and tradeoffs:

  • Explicit key functions: @cached(key=lambda sku, qty=1, **_: f"price:{sku}:{qty}"). Most robust and readable for hot paths; the generic canonicaliser is for a shared decorator used by many teams.
  • pickle.dumps of the arguments: not stable (depends on protocol, set order and object internals), and it invites unpickling later. Avoid.
  • Readable keys: f"{namespace}:{fn}:{version}:{sha256[:16]}" keeps keys debuggable in Redis (SCAN price_quote:*) and lets you invalidate a namespace by bumping version.
  • Pydantic models as arguments: canonicalise via model.model_dump(mode="json"), which gives a stable JSON-able structure for nested models.

Follow-ups:

  1. "Why did the old hash()-based key give a near-zero hit rate in Redis?" String and bytes hashes are salted per process (PYTHONHASHSEED), so each Gunicorn worker computed different keys for the same call. In a per-process dict that's invisible; in a shared cache, each worker only ever hits its own entries, and they are lost on every restart.
  2. "How could invalidation miss entries for Bengaluru?" Keyword arguments that equal their default were sometimes passed and sometimes omitted, and the key used kwargs.items() without binding to the signature, so the code that built keys and the code that invalidated them disagreed about which entry belonged to city="Bengaluru". Normalising through the signature, and invalidating by the same function, removes that class of bug.
  3. "The function's logic changes in a deploy, but old entries stay in Redis for 24 h. What do you do?" Bump version (or derive it from a hash of the function's source or the package version) so new code reads new keys and old entries expire naturally. During a rolling deploy both versions run side by side, and each must only read the keys it understands, which versioned keys guarantee.

Scoring: Strong: Binds arguments to the signature with defaults, builds a type-tagged canonical form (bool before int, datetime before date, normalised Decimal and UTC instants), sorts sets/dicts by canonical encoding, hashes with SHA-256, and tests stability across hash seeds; explains why hash() failed. Acceptable: Uses JSON with sort_keys and SHA-256 and binds the signature, but misses type collisions (1/True/1.0) or set ordering until tested. Red flag: Uses hash(), repr() or str() of the arguments as the key, or argues that key collisions in a cache are harmless.

SR-HT-D01 · An internal Python SDK for the payments platform, used by 40 teams

Hard & Tricky · Design · Senior (5–8 years) · library design, packaging, versioning, dependency management, sync and async APIs, retries, observability, deprecation · 45 min

Scenario:

Forty teams call the internal payments platform (create payment, refund, payout, status) over HTTP. Each team wrote its own client: some retry POST /payments on timeout (causing double charges), some don't set timeouts, three pin requests to old versions, and every API change breaks someone. You're asked to design payments-sdk, a Python package every team will use.

Constraints you learn quickly: - Consumers range from Django 3.2 monoliths on Python 3.9 to FastAPI services on 3.12, Airflow DAGs, and notebooks. - Half the services are async (FastAPI, aiohttp), half are sync. - Teams upgrade dependencies irregularly; some services are touched twice a year. - The platform API itself evolves (v1 → v2 adds idempotency_key and amounts in paise).

Clarifying questions:

  • Which Python versions must be supported, and for how long? (3.9 to 3.13 today; 3.9 support must have an end date.)
  • Is there a machine-readable API spec (OpenAPI)? Who owns it? (Yes, owned by the platform team; it's mostly accurate.)
  • What do consumers need beyond calls: webhooks verification, pagination helpers, test fakes? (Webhook signature verification and a test fake are top requests.)
  • How are services authenticated, and who manages credentials? (Service tokens from a secrets manager, rotated monthly.)
  • How will you know who uses which version and which methods? (Nobody knows today; that's part of the ask.)

Reference design:

Dependencies: be a good guest in 40 dependency trees. The SDK's dependency choices become everyone's constraints. Depend on one HTTP library (httpx, which supports both sync and async) with a wide version range (httpx>=0.24,<1), and Pydantic only if you support both v1 and v2 or can require v2 everywhere (that's often the real blocker; plain dataclasses for the public models avoid the issue). No pins (==) in a library; pins belong in applications' lock files. Zero import-time side effects: no network calls, no logging configuration, no global monkeypatching.

API shape. - Two clients with the same surface: PaymentsClient (sync) and AsyncPaymentsClient, generated from one implementation (shared request-building and response-parsing code; only the transport differs) so they can't drift. Both are context managers that own a connection pool; construct once per process, not per call. - Typed request/response models, money as integer paise (or Decimal) in a Money type with currency, py.typed shipped so mypy/pyright users get the types. - Explicit, safe defaults: connect/read timeouts always set; retries only for idempotent operations, or for POST with an idempotency key that the SDK generates if the caller doesn't pass one, reused across retries of that call (this fixes the double-charge bug structurally). Jittered backoff, a retry budget, and respect for Retry-After. - A small exception hierarchy: PaymentsError → ApiError(status, code, request_id), RateLimited, Timeout, ValidationError. Never leak httpx exceptions through the public API, so the transport can change. - Keyword-only arguments for everything except the obvious first one, so adding parameters never breaks callers.

Versioning and evolution. - Semantic versioning with a written policy: what counts as breaking (removing/renaming, changing defaults, exception types), deprecation for at least two minor releases with DeprecationWarning(stacklevel=2) and a changelog entry, and an announced removal version. - The SDK targets the platform's v2 API while keeping v1-shaped convenience methods as deprecated shims where possible, so teams upgrade the SDK first and change their code later. - CI matrix across supported Python versions and the min/max of each dependency range (for example nox sessions that install with uv pip install --resolution lowest-direct), plus a contract test suite run against the platform's staging API on every platform release.

Testing support for consumers. Ship payments_sdk.testing with a FakePaymentsClient (in-memory, same interface, configurable failures) and pytest fixtures, so teams stop mocking httpx internals, and their tests break when the SDK's interface changes rather than silently passing.

Observability and ownership. Every request sends a User-Agent like payments-sdk/2.3.1 python/3.12 service/<name>; the platform can then answer "who still uses 1.x / the deprecated refund path". Optional OpenTelemetry spans and a standard logger name (payments_sdk) with a NullHandler, never configuring logging for the host app. Request IDs from responses are attached to exceptions.

Distribution. Published to the internal package index (Artifactory/CodeArtifact) as a wheel with a pure-Python build; release automation from tags; a migration guide per major version; an owner team with an SLA for issues.

Trade-offs:

  • Generated client (from OpenAPI) vs hand-written: generation keeps it in sync with the API and is cheap to maintain, but generated code is often unidiomatic (bad names, no retries/idempotency, weak errors). A common middle ground: generate the low-level layer, hand-write the thin public layer that teams use.
  • One client with sync and async methods vs two clients: one class with create() and acreate() is easy to discover but doubles the surface on one object; two clients keep each simple. Either way, share the core logic.
  • Pydantic models vs dataclasses: Pydantic gives validation and JSON handling but forces a major version onto every consumer; dataclasses plus a small validation layer keep the dependency tree light.
  • Wide dependency ranges vs tested pins: wide ranges avoid resolver conflicts across 40 apps but increase the test matrix and the risk of an untested combination. Test min and max of each range in CI.
  • Centralised SDK vs letting teams call HTTP directly: the SDK is a product with an owner and a support cost; its value is that correctness (timeouts, retries, idempotency, webhook verification) is fixed once.

Deep-dive probes:

  • "Team X can't install payments-sdk 2.0 because it requires Pydantic 2 and they're on Pydantic 1 via an old FastAPI." Look for: understanding that a library's dependency is a constraint on the whole app; options are avoiding the dependency in the public surface, supporting both via a compatibility layer, or keeping 1.x on a maintenance branch with security fixes for a fixed period.
  • "A teammate wants the SDK to auto-retry create_payment on any timeout. Review that." Look for: a timeout means the outcome is unknown; retry is only safe with an idempotency key honoured by the server; the key must be stable across retries of the same logical call, not regenerated per attempt; and the SDK should surface "unknown outcome" distinctly when retries are exhausted.
  • "How do you remove a deprecated method that 6 teams still call?" Look for: usage telemetry by service from the User-Agent/metrics, direct outreach and PRs to those teams, CI warnings-as-errors for the deprecated path, a dated removal in a major version, and keeping the old major alive for security fixes for a defined period.
  • "What happens if the SDK creates a new client (and connection pool) per call inside a Celery task?" Look for: no connection reuse, TLS handshakes per call, file descriptor exhaustion under load; document and enforce one client per process (module-level lazy singleton or dependency injection), and be careful with clients created before a fork (pre-fork servers, Celery prefork) because sockets don't survive fork safely.

Rubric: Strong: Treats the SDK as a product whose dependencies constrain 40 apps (wide ranges, no pins, no import side effects); designs safe defaults (timeouts, idempotency keys reused across retries, clear exception hierarchy); a sync and async surface sharing one core; a written semver and deprecation policy with telemetry to drive removals; and a test fake for consumers. Acceptable: Proposes a typed client with retries, timeouts and semver, but misses dependency-conflict risks, generates new idempotency keys per retry, or ignores the sync/async split until prompted. Red flag: Pins exact dependency versions in the library, retries POSTs blindly on timeout, configures logging or does network calls at import time, or has no plan for evolving the API without breaking teams.

SR-HT-D02 · A 50-minute, flaky CI for the payments monolith

Hard & Tricky · Design · Senior (5–8 years) · testing strategy, pytest, flaky tests, test isolation, contract tests, CI performance, determinism · 40 min

Scenario: You join as the senior engineer on a payments monolith (Django, Postgres, Celery, Redis; ~300k lines of Python). The test suite has 14,000 tests and takes 50 minutes in CI. About 1 in 4 runs fails on a test that passes on retry, so developers hit "re-run" by habit. Last month, a real bug in refund rounding shipped because its test had been marked @pytest.mark.skip("flaky") six months earlier. Product wants faster releases; the CTO asks you for a plan to make CI fast and trustworthy within a quarter, without stopping feature work.

Clarifying questions:

  • What's in the 14,000 tests? (A rough census: ~9,000 Django TestCase tests hitting Postgres, ~3,000 pure unit tests, ~1,500 "integration" tests calling Redis/Celery and stubbed external services, ~500 Selenium tests.)
  • Is there data on which tests are slow and which are flaky? (CI keeps JUnit XML for 90 days; nobody has analysed it.)
  • How do tests run today? (Single job, pytest serially, one shared Postgres; some tests depend on fixtures loaded by earlier tests.)
  • What do the external integrations look like? (Bank and UPI gateways, SMS, a tax service; tests use a mix of mock.patch and a shared sandbox environment.)
  • What are the release mechanics? (Weekly release train; a failed main build blocks everyone.)

Reference design:

1. Measure first (week 1). Parse the JUnit XML history: per-test duration, failure rate, and flake rate (failed then passed on the same commit). Typically a small set of tests accounts for most of the time and most of the flakes. Publish a dashboard; it's the baseline for every later claim.

2. Make flakes visible and owned, not ignored. - Policy: a flaky test is a bug. It goes into quarantine (a marker that runs it in a separate, non-blocking job that still reports results) with an owner and a ticket with a deadline. skip without a ticket is banned by a lint rule; quarantine has a size budget. - Fix the root causes, which in Python suites are usually a handful of patterns: - Time: tests using datetime.now(), date.today() or IST/UTC day boundaries (fails between 00:00 and 05:30 IST). Inject a clock or freeze time (time-machine), and add a CI job that runs with TZ=Asia/Kolkata and one with TZ=UTC. - Order dependence and shared state: tests that rely on data from earlier tests, module-level caches, lru_cache, settings mutated without restore, singletons. Detect with pytest-randomly (random order with a printed seed) and fix by making fixtures own their data. - Randomness and hash order: unseeded random, factories generating colliding unique values, assertions on set order. Seed per test (the seed is printed so failures replay). - Async and Celery timing: sleep(1) then assert. Replace with eager Celery in unit tests, and polling with a deadline (or explicit synchronisation) in integration tests. - External sandboxes: replace calls to shared sandboxes in the main suite with fakes/recorded responses; keep a small, separate, non-blocking suite against real sandboxes.

3. Make it fast. - Parallelise: pytest-xdist with one Postgres database per worker (pytest-django creates a test_<db>_gw0, gw1, ... database per xdist worker; Django's own --parallel clones one per process), then shard across CI machines by historical duration. This alone usually takes 50 minutes to under 10 on enough runners, but only after order dependence is fixed, which is why step 2 comes first. - Cheaper DB tests: use transactions rolled back per test (TestCase) instead of truncation (TransactionTestCase) wherever possible; build fixtures with factories instead of loading large JSON fixtures per test. - Test selection on PRs: run tests affected by changed files (coverage-based mapping or import graph) plus a smoke set, with the full suite on main and nightly. Selection must be conservative: when in doubt, run more. - Move tests down the pyramid: much of the 9,000 DB-backed tests check pure logic (fees, rounding, state machines). Extract that logic into pure functions/modules tested without the DB, and add property-based tests (Hypothesis) for money and rounding invariants, which is exactly where the refund bug lived.

4. Contracts at the boundaries. For bank/UPI/SMS/tax integrations, define the contract once: recorded, versioned example payloads (including error responses) validated against Pydantic models, a fake that implements the contract and is used by unit tests, and a scheduled contract test against the real sandbox that alerts when the provider changes. create_autospec (not bare Mock) where mocks remain, so signature drift fails tests.

5. Guardrails so it stays fixed. CI budget alerts (p50/p90 duration), a flake-rate SLO per week, required ownership metadata for slow tests, and a rule that new tests can't be added to quarantine directly.

Trade-offs:

  • Retries on failure (pytest-rerunfailures): they make CI green quickly but hide real concurrency and timing bugs, including ones that happen in production. Acceptable only as a temporary, reported measure for quarantined tests, never globally.
  • Test selection vs full runs: selection cuts PR time drastically but can miss indirect effects (settings, migrations, shared fixtures); keep full runs on main and before release.
  • Fakes vs mocks vs real services: fakes cost effort to build and must be kept honest by contract tests; mocks are cheap but couple tests to implementation details; real sandboxes are realistic but slow and flaky. Use each at its own layer.
  • Rewriting tests vs fixing infrastructure: parallelism and per-worker DBs give the biggest speed-up for the least code change, but only work once isolation is fixed; rewriting DB tests as unit tests is slower work with lasting benefit. Sequence: measure → isolation → parallelism → pyramid.
  • Feature work vs test investment: a fixed budget (e.g. 20% of the team for the quarter) with visible metrics makes the trade-off explicit to product.

Deep-dive probes:

  • "A test fails only when run with xdist, never alone. How do you debug it?" Look for: reproduce with the printed --randomly-seed, bisect the tests that ran earlier in the same worker (run that worker's test list in order and halve it until the failure needs only one predecessor), look for shared DB rows, module-level state, environment variables or files in a shared temp directory, and fix ownership of the state rather than pinning the order.
  • "How would a property-based test have caught the refund rounding bug?" Look for: invariants such as "sum of partial refunds never exceeds the captured amount", "refund of the full amount returns exactly the captured paise", tested with Hypothesis over amounts, splits and GST rates, with shrinking producing a minimal failing case.
  • "Developers say test selection let a broken migration reach main." Look for: always running migration and settings-related checks, mapping non-Python files (migrations, templates, settings) to test sets, and treating main's full run as the gate for the release train.
  • "How do you convince the team to stop pressing re-run?" Look for: making flakes cheap to report (auto-detection and auto-quarantine with an owner), publishing the flake dashboard, and leading by fixing the top 10 flakes first to show the payoff.

Rubric: Strong: Starts from data (durations and flake rates), names the concrete Python flake sources (time, order, shared state, randomness, async timing, external sandboxes) and their fixes, sequences isolation before parallelism, reshapes the pyramid with pure logic and property-based tests for money, adds contract tests at integration boundaries, and puts guardrails and ownership in place. Acceptable: Proposes xdist, sharding and fixing flaky tests, but without a measurement step, without addressing why parallel runs will expose order dependence, or without a quarantine and ownership policy. Red flag: Recommends global automatic retries or skipping flaky tests, buying bigger CI machines as the main plan, or deleting slow tests without understanding what they protect.

Role-wise questions

SR-DE-C01 · Watermark-based dedup of an out-of-order event stream in bounded memory

Hard · Coding · Senior (5–8 years) · streaming, deduplication, watermarks, late data, bounded state, heapq, at-least-once delivery · 30 min

Problem:

A ride-hailing platform streams trip events from driver apps through Kafka into the warehouse. Delivery is at-least-once (retries after flaky mobile networks, consumer restarts), and events arrive out of order by up to a minute. Downstream billing needs every event_id emitted exactly once. The consumer runs for weeks, so it cannot remember every ID it has ever seen.

Implement WatermarkDeduper(allowed_lateness) with process(event) -> "emit" | "duplicate" | "late":

  • The watermark is max event_time seen so far - allowed_lateness (None before the first event). Only events that are emitted can advance it.
  • An event with event_time < watermark is "late": it goes to a side output for a reconciliation job, and is never emitted by this consumer.
  • Otherwise, an event_id already emitted is a "duplicate"; a new one is "emit".
  • Memory must stay bounded by the number of distinct events inside the lateness window, not by the length of the stream. Expose state_size (number of remembered IDs) and watermark.
  • allowed_lateness < 0 raises ValueError.

A redelivered copy of an event carries the same event_time as the original.

Signature: class WatermarkDeduper: def __init__(self, allowed_lateness: int) -> None: ... def process(self, event: Event) -> Literal["emit", "duplicate", "late"]: ...

Constraints: event_time is integer epoch seconds (UTC). Up to ~20,000 events/s per partition, so process must be O(log n) or better. Standard library only. One deduper per partition; you may assume a single thread calls it.

Examples:

  • d = WatermarkDeduper(60); d.process(Event('a', 1000)); d.process(Event('a', 1000)) → 'emit', then 'duplicate'
  • d.process(Event('b', 990)); d.process(Event('c', 1100)) → 'emit' (out of order but within 60 s), 'emit'; watermark is now 1040
  • d.process(Event('d', 1039)); d.process(Event('a', 1000)) → 'late', 'late' (the redelivered 'a' must not be emitted again, even though its ID was evicted)

Hints:

  1. When is it safe to forget an ID? Think about what happens to a redelivered copy of it afterwards.
  2. If you evict IDs older than the watermark, a copy of an evicted event is itself older than the watermark. Which check has to run first, so eviction can never cause a second emit?
  3. To evict in O(log n), keep a min-heap of (event_time, event_id) next to the set of IDs and pop while the top is older than the watermark.

Reference solution:

import heapq
from dataclasses import dataclass
from typing import Literal

Verdict = Literal["emit", "duplicate", "late"]


@dataclass(frozen=True)
class Event:
    event_id: str
    event_time: int          # epoch seconds, UTC
    payload: str = ""


class WatermarkDeduper:
    """Exactly-once *output* for an at-least-once, out-of-order stream, in bounded memory.

    watermark = max event_time seen - allowed_lateness. Events older than the
    watermark are 'late' (routed to a side output); ids are remembered only while
    a copy of them could still be accepted.
    """

    def __init__(self, allowed_lateness: int) -> None:
        if allowed_lateness < 0:
            raise ValueError("allowed_lateness must be >= 0")
        self.allowed_lateness = allowed_lateness
        self._max_time: int | None = None
        self._seen: set[str] = set()
        self._expiry: list[tuple[int, str]] = []      # min-heap of (event_time, event_id)

    @property
    def watermark(self) -> int | None:
        return None if self._max_time is None else self._max_time - self.allowed_lateness

    @property
    def state_size(self) -> int:
        return len(self._seen)

    def process(self, event: Event) -> Verdict:
        wm = self.watermark
        if wm is not None and event.event_time < wm:
            return "late"                 # never consult state: it may have been evicted
        if event.event_id in self._seen:
            return "duplicate"
        self._seen.add(event.event_id)
        heapq.heappush(self._expiry, (event.event_time, event.event_id))
        if self._max_time is None or event.event_time > self._max_time:
            self._max_time = event.event_time
            self._evict()
        return "emit"

    def _evict(self) -> None:
        wm = self.watermark
        while self._expiry and self._expiry[0][0] < wm:
            _, event_id = heapq.heappop(self._expiry)
            self._seen.discard(event_id)

Complexity: process is O(log w) amortised, where w is the number of distinct events inside the lateness window (heap push, plus each ID is popped exactly once). Space is O(w): with 20,000 events/s and 60 s lateness, about 1.2 million IDs, roughly 200–250 MB of CPython strings, set slots and heap tuples (measured with tracemalloc), which is worth knowing before choosing in-process state.

Tests:

d = WatermarkDeduper(allowed_lateness=60)
assert d.watermark is None
assert d.process(Event("a", 1000)) == "emit"
assert d.process(Event("a", 1000)) == "duplicate"
assert d.process(Event("b", 990)) == "emit"            # out of order but within lateness
assert d.process(Event("c", 1100)) == "emit"           # watermark -> 1040
assert d.watermark == 1040
assert d.process(Event("d", 1039)) == "late"
assert d.process(Event("e", 1040)) == "emit"           # exactly at the watermark is on time
assert d.process(Event("e", 1040)) == "duplicate"
# a and b were evicted; a redelivery of 'a' must NOT be emitted a second time
assert d.state_size == 2, d.state_size
assert d.process(Event("a", 1000)) == "late"
# a late event must not move the watermark
assert d.watermark == 1040

# bounded memory on a long stream with redeliveries
d2 = WatermarkDeduper(allowed_lateness=30)
emitted = 0
max_state = 0
for t in range(10_000):
    for eid in (f"e{t}", f"e{t}", f"e{max(t - 5, 0)}"):    # dup + a redelivery 5 s later
        if d2.process(Event(eid, t if eid == f"e{t}" else max(t - 5, 0))) == "emit":
            emitted += 1
    max_state = max(max_state, d2.state_size)
assert emitted == 10_000, emitted
assert max_state <= 31, max_state

# redelivery long after eviction is late, not re-emitted
assert d2.process(Event("e10", 10)) == "late"

# zero lateness: anything older than the max is late
z = WatermarkDeduper(0)
assert [z.process(Event(i, t)) for i, t in [("x", 5), ("y", 4), ("z", 5), ("x", 5)]] == \
    ["emit", "late", "emit", "duplicate"]

try:
    WatermarkDeduper(-1)
except ValueError:
    pass
else:
    raise AssertionError("negative lateness accepted")

A common wrong solution (the tests catch it):

from dataclasses import dataclass
from typing import Literal

Verdict = Literal["emit", "duplicate", "late"]


@dataclass(frozen=True)
class Event:
    event_id: str
    event_time: int
    payload: str = ""


class WatermarkDeduper:
    def __init__(self, allowed_lateness: int) -> None:
        if allowed_lateness < 0:
            raise ValueError("allowed_lateness must be >= 0")
        self.allowed_lateness = allowed_lateness
        self._max_time: int | None = None
        self._seen: dict[str, int] = {}

    @property
    def watermark(self) -> int | None:
        return None if self._max_time is None else self._max_time - self.allowed_lateness

    @property
    def state_size(self) -> int:
        return len(self._seen)

    def process(self, event: Event) -> Verdict:
        if event.event_id in self._seen:
            return "duplicate"
        self._seen[event.event_id] = event.event_time
        if self._max_time is None or event.event_time > self._max_time:
            self._max_time = event.event_time
            wm = self.watermark
            self._seen = {k: t for k, t in self._seen.items() if t >= wm}   # bounded state
        return "emit"

Alternatives and tradeoffs:

  • Rebuild the set on every watermark advance (as the broken version does): correct eviction but O(w) per event; it falls over at real rates.
  • Time-bucketed sets (one set per second or minute, drop whole buckets past the watermark): O(1) per event and cheaper eviction, at the cost of coarser memory bounds. This is what most production implementations do.
  • Bloom filter per bucket: far less memory, but false positives mean dropping real events, which billing can't accept without a reconciliation path.
  • Push it to the sink: an idempotent MERGE/upsert on event_id in the warehouse makes the stream consumer's dedup a cost optimisation rather than a correctness requirement. Senior answers usually do both: bounded in-stream dedup plus an idempotent sink.
  • Frameworks (Flink keyed state with TTL, Spark dropDuplicatesWithinWatermark) implement the same contract; the question is whether you understand what they guarantee.

Follow-ups:

  1. "The consumer crashes and restarts from the last committed Kafka offset. What breaks, and how do you fix it?" In-memory state is lost, so events between the committed offset and the crash are re-read and re-emitted: duplicates downstream. Either checkpoint the deduper state atomically together with the offset (what Flink does), or rebuild it on startup by replaying allowed_lateness worth of input before emitting, or rely on an idempotent sink keyed on event_id.
  2. "One driver's phone has a clock set to 2030. What happens to the watermark, and how do you defend against it?" Its event jumps max_time to 2030, the watermark leaps forward and every normal event becomes 'late'. Use server-side ingestion time for the watermark, or clamp event_time to ingest_time + small skew and quarantine outliers, and compute the watermark from a low percentile across partitions rather than a single max.
  3. "Billing asks for an SLA: what fraction of events are 'late' and what happens to them?" Emit a metric for late counts and lateness distribution per partition, alert above a threshold, and land late events in a side table that a daily reconciliation job merges idempotently into billing. Tune allowed_lateness from the measured p99.9 delay, trading memory and output latency for fewer late events.

Scoring: Strong: Checks lateness before the ID lookup and explains why that ordering is what makes eviction safe; heap or bucketed eviction with bounded state; the watermark only moves forward on accepted events; discusses restart semantics and clock skew without being led. Acceptable: Correct dedup with a watermark and eviction, but evicts by rescanning the dict or needs the hint to see that an evicted ID can be re-emitted. Red flag: Keeps every ID forever, keeps the 'last N IDs' regardless of time, or says Kafka's exactly-once makes consumer-side dedup unnecessary.

SR-DE-C02 · A CI gate that flags breaking changes in a table's data contract

Medium · Coding · Senior (5–8 years) · data contracts, schema evolution, compatibility, recursion, dataclasses, CI gates · 25 min

Problem:

Your team owns the orders table that 14 downstream teams (finance, fraud, growth dashboards) read. Producers propose schema changes in pull requests; you want a CI check that blocks changes that would break existing consumers, and lets everything else through without a meeting.

Implement breaking_changes(current, proposed). A consumer was written against current and will now read data produced with proposed. Report, as messages sorted by field path:

  • "<path>: removed" for a field in current that is missing from proposed.
  • "<path>: type <cur> -> <new> is not safe" when values of the proposed type cannot always be read as the current type. A type is safely readable as another if it is the same, or reachable through these lossless steps (transitively): int16 → int32 → int64, int32 → float64, float32 → float64, date → timestamp.
  • "<path>: became nullable" when a non-null field becomes nullable (consumers don't handle nulls they've never seen).
  • Nested structs (a Field whose type is itself a schema dict) are checked recursively with dotted paths, e.g. address.pincode. Swapping a struct for a primitive or vice versa is "<path>: struct/primitive mismatch".

New fields, tightening nullable → non-null, and narrowing a type (e.g. int64 → int32) are not breaking. Don't mutate inputs.

Signature: def breaking_changes(current: dict[str, Field], proposed: dict[str, Field]) -> list[str]:

Constraints: Schemas have up to a few hundred fields and nesting depth up to 5. Standard library only. Output must be deterministic (it is posted as a PR comment and diffed between runs). If a field has both a type and a nullability problem, report the type message first.

Examples:

  • current {'qty': int32}, proposed {'qty': int64} → ['qty: type int32 -> int64 is not safe'] # consumers declared int32
  • current {'amount_paise': int64}, proposed {'amount_paise': int32, 'gst_paise': int64} → [] # narrowing and additions are fine
  • current {'address': {pincode: string}}, proposed {'address': {pincode: int32}} → ['address.pincode: type string -> int32 is not safe']

Hints:

  1. Whose reader matters? Phrase each rule as: can a value written with the proposed type be read as the current type?
  2. Precompute the transitive closure of the safe-read pairs once, instead of walking the graph per field.
  3. For nested structs, recurse with a path prefix and merge the results before one final sort.

Reference solution:

from __future__ import annotations

from dataclasses import dataclass
from typing import Union

Schema = dict[str, "Field"]


@dataclass(frozen=True)
class Field:
    type: Union[str, Schema]      # a primitive name, or a nested struct schema
    nullable: bool = False


# (narrow, wide): a value of `narrow` can always be read as `wide` without loss.
_SAFE_READS = {
    ("int16", "int32"), ("int32", "int64"), ("int32", "float64"),
    ("float32", "float64"), ("date", "timestamp"),
}


def _closure(pairs: set[tuple[str, str]]) -> set[tuple[str, str]]:
    closed = set(pairs)
    while True:
        extra = {(a, d) for a, b in closed for c, d in closed if b == c} - closed
        if not extra:
            return closed
        closed |= extra


SAFE_READS = _closure(_SAFE_READS)


def breaking_changes(current: Schema, proposed: Schema, _prefix: str = "") -> list[str]:
    """Changes in `proposed` that would break consumers written against `current`.

    Consumers read data produced with `proposed` using `current`'s expectations, so a
    proposed type must be readable *as* the current type, and a non-null column must
    stay non-null. Returns messages sorted by field path.
    """
    found: list[tuple[str, int, str]] = []
    for name, cur in current.items():
        path = f"{_prefix}{name}"
        new = proposed.get(name)
        if new is None:
            found.append((path, 0, f"{path}: removed"))
            continue
        cur_struct, new_struct = isinstance(cur.type, dict), isinstance(new.type, dict)
        if cur_struct and new_struct:
            for msg in breaking_changes(cur.type, new.type, f"{path}."):
                found.append((msg.split(":", 1)[0], 0, msg))
        elif cur_struct or new_struct:
            found.append((path, 0, f"{path}: struct/primitive mismatch"))
        elif cur.type != new.type and (new.type, cur.type) not in SAFE_READS:
            found.append((path, 0, f"{path}: type {cur.type} -> {new.type} is not safe"))
        if new.nullable and not cur.nullable:
            found.append((path, 1, f"{path}: became nullable"))
    return [msg for _, _, msg in sorted(found)]

Complexity: O(F) field comparisons for F fields across all nesting levels, plus O(V log V) to sort V violations. The closure of the safe-read table is computed once at import; it is tiny, so the naive fixed-point loop is fine.

Tests:

current = {
    "order_id": Field("int64"),
    "amount_paise": Field("int64"),
    "qty": Field("int32"),
    "coupon": Field("string", nullable=True),
    "placed_on": Field("timestamp"),
    "address": Field({"pincode": Field("string"), "city": Field("string", nullable=True)}),
}

assert breaking_changes(current, current) == []

# Additions, narrowing a type the consumer reads, and tightening nullability are all safe.
safe = dict(current,
            gst_paise=Field("int64"),                         # new column
            amount_paise=Field("int32"),                      # int32 read as int64: fine
            placed_on=Field("date"),                          # date read as timestamp: fine
            coupon=Field("string", nullable=False))           # never-null is still valid
assert breaking_changes(current, safe) == [], breaking_changes(current, safe)

# Transitive safe read: int16 values read as int64.
assert breaking_changes({"x": Field("int64")}, {"x": Field("int16")}) == []

# Widening what the producer writes is the classic mistake: consumers expect int32.
assert breaking_changes(current, dict(current, qty=Field("int64"))) == \
    ["qty: type int32 -> int64 is not safe"]
# int64 -> float64 loses precision above 2**53, so it is not a safe read either way.
assert breaking_changes({"id": Field("float64")}, {"id": Field("int64")}) == \
    ["id: type float64 -> int64 is not safe"]
assert breaking_changes({"id": Field("int64")}, {"id": Field("float64")}) == \
    ["id: type int64 -> float64 is not safe"]

bad = {
    "amount_paise": Field("float64", nullable=True),          # type and nullability
    "qty": Field("int32"),
    "coupon": Field("string", nullable=True),
    "placed_on": Field("string"),
    "address": Field({"pincode": Field("int32"), "city": Field("string", nullable=True),
                      "landmark": Field("string", nullable=True)}),
    "customer_ref": Field("string"),                          # order_id was "renamed"
}
assert breaking_changes(current, bad) == [
    "address.pincode: type string -> int32 is not safe",
    "amount_paise: type int64 -> float64 is not safe",
    "amount_paise: became nullable",
    "order_id: removed",
    "placed_on: type timestamp -> string is not safe",
], breaking_changes(current, bad)

# Nested removals and struct/primitive swaps.
assert breaking_changes(current, dict(current, address=Field({"pincode": Field("string")}))) == \
    ["address.city: removed"]
assert breaking_changes(current, dict(current, address=Field("string"))) == \
    ["address: struct/primitive mismatch"]
# Inputs are not mutated.
assert set(current) == {"order_id", "amount_paise", "qty", "coupon", "placed_on", "address"}

A common wrong solution (the tests catch it):

from __future__ import annotations

from dataclasses import dataclass
from typing import Union

Schema = dict[str, "Field"]


@dataclass(frozen=True)
class Field:
    type: Union[str, Schema]      # a primitive name, or a nested struct schema
    nullable: bool = False


# (narrow, wide): a value of `narrow` can always be read as `wide` without loss.
_SAFE_READS = {
    ("int16", "int32"), ("int32", "int64"), ("int32", "float64"),
    ("float32", "float64"), ("date", "timestamp"),
}


def _closure(pairs: set[tuple[str, str]]) -> set[tuple[str, str]]:
    closed = set(pairs)
    while True:
        extra = {(a, d) for a, b in closed for c, d in closed if b == c} - closed
        if not extra:
            return closed
        closed |= extra


SAFE_READS = _closure(_SAFE_READS)


def breaking_changes(current: Schema, proposed: Schema, _prefix: str = "") -> list[str]:
    """Changes in `proposed` that would break consumers written against `current`.

    Consumers read data produced with `proposed` using `current`'s expectations, so a
    proposed type must be readable *as* the current type, and a non-null column must
    stay non-null. Returns messages sorted by field path.
    """
    found: list[tuple[str, int, str]] = []
    for name, cur in current.items():
        path = f"{_prefix}{name}"
        new = proposed.get(name)
        if new is None:
            found.append((path, 0, f"{path}: removed"))
            continue
        cur_struct, new_struct = isinstance(cur.type, dict), isinstance(new.type, dict)
        if cur_struct and new_struct:
            for msg in breaking_changes(cur.type, new.type, f"{path}."):
                found.append((msg.split(":", 1)[0], 0, msg))
        elif cur_struct or new_struct:
            found.append((path, 0, f"{path}: struct/primitive mismatch"))
        elif cur.type != new.type and (cur.type, new.type) not in SAFE_READS:  # producer may widen
            found.append((path, 0, f"{path}: type {cur.type} -> {new.type} is not safe"))
        if new.nullable and not cur.nullable:
            found.append((path, 1, f"{path}: became nullable"))
    return [msg for _, _, msg in sorted(found)]

Alternatives and tradeoffs:

  • Use the format's own rules: Avro and Protobuf have well-defined compatibility modes (Confluent Schema Registry's BACKWARD / FORWARD / FULL). If your contracts are Avro or Protobuf, enforce them in the registry rather than reimplementing; this checker is the right tool for warehouse/Parquet tables and pandas/pydantic contracts that have no registry.
  • Contract tests from consumers (consumer-driven contracts): each consumer declares the fields and types it actually uses; a removal of an unused field is then not breaking. More precise, more process.
  • Semantic checks the type system can't see (amount switching from rupees to paise, a timezone change) need a versioned contract with owners and a changelog; no schema diff catches them.

Follow-ups:

  1. "This checker says the change is breaking. The producer team says they really need to change qty to int64. How do you ship it?" Expand–contract: add qty_v2 int64 alongside qty, dual-write (fail or clamp with an alert when a value overflows int32), migrate consumers with a deadline and usage tracking (query logs), then deprecate and remove qty in a later release. Or publish a new versioned table/topic and run both for a migration window.
  2. "Why is int64 → float64 not in the safe list, when every database will happily cast it?" float64 has a 53-bit mantissa, so integers above 2**53 are rounded. For IDs and paise amounts this silently corrupts values (and can create false join matches). A cast that succeeds is not the same as a lossless read.
  3. "Where else would you run this check besides CI?" At ingestion time: compare each incoming batch's inferred schema with the contract and quarantine the batch on a breaking diff, because producers don't always go through your CI (a vendor file, an upstream team's hotfix). And in a nightly job against the actual warehouse table to catch drift made by hand.

Scoring: Strong: Gets the direction right (proposed must be readable as current) and explains why from the consumer's point of view, precomputes the transitive closure, handles nesting with dotted paths, deterministic ordering, and connects int64→float64 to precision loss. Proposes expand–contract for the necessary breaking change. Acceptable: Correct flat-schema rules after clarifying the direction; nesting or transitivity added with a hint. Red flag: Treats any 'widening' as safe without asking who reads the data, or proposes skipping the check and 'telling consumers on Slack'.

SR-DE-D01 · Change data capture from sharded Postgres into the lakehouse

Hard · Design · Senior (5–8 years) · CDC, Debezium, Kafka, ordering, deletes, upserts, schema evolution, backfills, pyarrow, Iceberg · 45 min

Scenario:

A quick-commerce company in India runs its order system on 8 PostgreSQL shards (sharded by store_id). Analytics reads from a nightly pg_dump-and-load job, so the ops dashboard ("orders stuck in packing for more than 10 minutes, per dark store") is always yesterday's news. Leadership wants the orders and order_items tables in the lakehouse (Parquet on object storage, queried by Trino/Spark) with freshness under 5 minutes.

Numbers: ~4 million orders/day, each order updated ~8 times as it moves through statuses (placed → packed → picked up → delivered/cancelled), so ~35 million row changes/day for orders and ~3× that for items; peaks at 3× average between 19:00 and 22:00 IST. Rows are also hard-deleted by a GDPR erasure job. Developers add columns to these tables about twice a month.

Your team writes Python; there is a managed Kafka cluster. Design the pipeline from the Postgres write-ahead log to queryable tables, including the initial load and how you would rebuild a table after a bug.

Clarifying questions:

  • What does 'current' mean for consumers: the latest state of each order, the full change history, or both? (Both: a current-state table for dashboards, a history table for the data science team's funnel analysis.)
  • Are order IDs globally unique across shards, or only per shard? (Per shard; the key is (shard_id, order_id).)
  • Can we run Debezium (Kafka Connect) against the shards, and do we have logical replication slots available? (Yes; DBAs are nervous about slot lag filling disks.)
  • Which table format does the lakehouse support: plain Parquet, Iceberg, Delta, Hudi? (Iceberg is available via the catalogue; Trino reads it.)
  • How must hard deletes appear downstream: removed from current state, and also scrubbed from history for GDPR? (Yes to both, within 30 days.)
  • What freshness and correctness do the consumers need: is 5-minute eventual consistency OK, and is any number financial? (Dashboards: yes. Finance still uses the nightly reconciled batch.)
  • Who owns the source schema, and is there any process when a column is renamed or its type changes?

Reference design:

1. Capture: Debezium per shard → Kafka. One connector per shard reads the logical replication slot (pgoutput) and publishes change events to cdc.orders and cdc.order_items, keyed by the primary key (shard_id, order_id), so all changes to one row land on one partition in commit order. Each event carries op (c/u/d/r for snapshot), before/after, the source LSN and transaction id, and commit timestamp. Use Avro or Protobuf with a schema registry; deletes produce a delete event plus a tombstone. Alert on replication-slot lag (bytes and seconds) so a stuck consumer doesn't fill the primary's disk.

2. Land raw, append-only (bronze). A Python consumer group (confluent-kafka) reads micro-batches of ~1–2 minutes, converts them to Arrow record batches with an explicit schema (decimals as decimal128 paise or int64, timestamps as UTC timestamp[us, tz=UTC], never inferred), and appends them to an Iceberg orders_changes table partitioned by commit date. Commit Kafka offsets only after the Iceberg commit succeeds; on crash, the batch is re-read, so bronze writes are made idempotent by storing (topic, partition, offset_range) in the Iceberg snapshot summary and skipping a batch whose range is already committed. This table is the replayable source of truth for everything downstream.

3. Apply to current state (silver). Every few minutes a job takes the new bronze rows, keeps the latest change per key by (LSN, event order) (never by wall-clock updated_at, which ties and skews across shards), and runs an Iceberg MERGE INTO orders_current (Spark or Trino; or pyiceberg with copy-on-write for small partitions): updates and inserts upsert, op = d deletes. Because the merge only applies a change if its LSN is newer than the stored one, replays and out-of-order batches are harmless: the apply is idempotent and order-insensitive.

4. Initial load and rebuilds. Debezium's incremental snapshot (signal table, chunked by PK) emits op = r rows interleaved with live changes, with watermarking so a snapshot row never overwrites a newer live change. Rebuilding silver after a bug = truncate silver and replay bronze with the fixed code (same job, different range); no need to touch production Postgres.

5. Schema evolution. Additive, nullable columns are allowed automatically: the registry accepts them, the Python consumer maps by field name, and Iceberg's schema evolution adds the column. Renames, type changes and drops are blocked by registry compatibility rules (and a CI check on the migration PRs), and go through expand–contract. Unknown fields never shift positions because nothing is positional.

6. GDPR deletes. Current state handles deletes via MERGE. For history, run a weekly job that rewrites affected bronze/history files with the erased keys removed (Iceberg row-level delete + rewrite_data_files + snapshot expiry), and keep an erasure log proving it happened.

7. Observability and SLOs. End-to-end freshness per table (now - max(commit_ts) in silver), slot lag, consumer lag per partition, MERGE duration, rows changed per batch vs a seasonal baseline, and a row-count reconciliation per shard: hourly count(*) and sum of amount_paise from a replica vs silver, with an alert on drift. Small-file compaction runs hourly on the hot partitions.

Python-specific: one consumer process per few partitions (the work is decode + Arrow conversion, which is CPU-bound, so scale with processes or more consumer instances, not threads); bound memory per micro-batch by bytes, not message count; poll in a loop that keeps calling poll() during long writes (or uses pause()/resume()) so the group doesn't rebalance on max.poll.interval.ms.

Trade-offs:

  • Debezium + Kafka vs a managed CDC service (DMS, Datastream, Fivetran). Managed is faster to start and fewer moving parts; Debezium gives control over snapshots, ordering, schema registry and cost at 100M+ changes/day. With an existing Kafka team, Debezium; without one, managed.
  • MERGE on every micro-batch vs merge-on-read. Copy-on-write MERGE gives fast reads but rewrites files every few minutes (write amplification, small files). Merge-on-read (Iceberg position/equality deletes) makes writes cheap and reads slower until compaction. For a 5-minute SLO with heavy updates, merge-on-read plus frequent compaction of hot partitions is the usual answer.
  • Ordering by LSN vs updated_at. LSN is exact within a shard; across shards it doesn't matter because keys never move between shards. updated_at is set by application clocks and is not unique, so ties pick the wrong winner.
  • Exactly-once Kafka transactions vs idempotent sinks. Kafka EOS doesn't cover an external sink like Iceberg; an idempotent apply keyed on LSN is simpler and survives replays from any point.
  • One pipeline per table vs a generic framework. Two tables today; twenty next year. A small config-driven framework (table, key, partitioning, contract) pays off after the third table, not the first.

Deep-dive probes:

  • "An order goes placed → cancelled → (support reverts) placed again, all within one micro-batch, and the batch arrives after a replay of an older batch." Look for: dedup to the latest change per key by LSN within the batch, and a MERGE condition WHEN MATCHED AND src.lsn > tgt.lsn, so the older replay can't regress state.
  • "The consumer was down for 6 hours during the IPL final." Look for: Kafka retention well beyond the worst outage (e.g. 7 days), catch-up with larger batches and more consumer instances, slot lag protected because Debezium kept running, freshness SLO breach visible on the dashboard, and no special code path: catch-up is the same idempotent job.
  • "A developer changed amount from numeric(10,2) to numeric(12,2) and another renamed status to order_status." Look for: widening the decimal is compatible if the Arrow/Iceberg type is widened too (registry permits it, Iceberg supports decimal precision widening); the rename is blocked and must become add-new, dual-write, backfill, deprecate.
  • "Silver has 0.3% fewer orders than Postgres for one shard." Look for: per-shard reconciliation narrowing it down to time ranges, checking whether deletes or snapshot/stream watermark handling dropped rows, replaying bronze for that range, and a test fixture reproducing the interleaving.
  • "The consumer's memory climbs until it is OOM-killed at peak." Look for: batches bounded by bytes, Arrow buffers released between batches, not accumulating Python dicts per message, and measuring with tracemalloc or by watching RSS per batch size.

Rubric: Strong: Log-based CDC keyed by primary key with LSN ordering; an immutable bronze layer that makes rebuilds a replay; an idempotent, order-insensitive MERGE with deletes; snapshot/stream interleaving handled; schema evolution gated by a registry with expand–contract for breaking changes; GDPR erasure in history; concrete freshness and reconciliation metrics. Explicit about merge-on-read vs copy-on-write and small files. Acceptable: Debezium → Kafka → periodic MERGE on the primary key with sensible monitoring, but orders changes by updated_at, or needs prompting on deletes, replay idempotency or the initial snapshot. Red flag: Polls WHERE updated_at > last_run on the primary (misses deletes, hammers the DB, loses same-timestamp rows), appends changes without a current-state table, or assumes exactly-once delivery end to end.

SR-DE-O01 · A left join turns 64-bit merchant IDs into floats and doubles a payout

Medium & Tricky · Output · Senior (5–8 years) · pandas merge, dtypes, int64 to float64, data contracts, join fan-out, reconciliation · 8 min

Question: A nightly refund-settlement job in a payments company enriches orders with refunds, then joins to the merchant table to find the settlement account. Merchant IDs are 64-bit integers generated by a Snowflake-style ID service. The job has run fine for months; on Monday finance reports one refund paid out to two different banks. What does this print?

import pandas as pd

orders = pd.DataFrame({"order_id": [101, 102, 103]})
refunds = pd.DataFrame({
    "order_id": [101, 103],
    "merchant_id": [9007199254740993, 42],
})
merchants = pd.DataFrame({
    "merchant_id": [9007199254740992, 9007199254740993, 42],
    "settlement_acct": ["HDFC-01", "ICICI-07", "SBI-11"],
})

report = orders.merge(refunds, on="order_id", how="left")
print(report["merchant_id"].dtype, report["merchant_id"].tolist())

payout = report.merge(merchants, on="merchant_id", how="left")
print(len(report), "->", len(payout))
print(payout[["order_id", "settlement_acct"]].to_string(index=False))

Expected output:

float64 [9007199254740992.0, nan, 42.0]
3 -> 4
 order_id settlement_acct
      101         HDFC-01
      101        ICICI-07
      102             NaN
      103          SBI-11

The trap: "int64 [9007199254740993, nan, 42], then 3 -> 3, with order 101 paid to ICICI-07." Candidates know a left join can introduce missing values but picture them as "an int column with a null in it". Others say float64 but assume the value survives (9007199254740993.0) because the number "looks" exact, and nobody expects a many-to-one lookup to add rows.

Explanation:

Step 1, the dtype change. Order 102 has no refund, so the left join must put a missing value in merchant_id. Classic NumPy int64 has no missing value, so pandas upcasts the whole column to float64 and uses NaN. A float64 has a 53-bit mantissa: every integer above 2⁵³ = 9,007,199,254,740,992 can no longer be represented exactly. 9007199254740993 (2⁵³ + 1) rounds to the nearest even representable value, 9007199254740992. Nothing warns; the column just holds a different number.

Step 2, the fan-out. Joining a float64 key against the int64 key in merchants, pandas brings both sides to a common dtype, so the merchant IDs …992 and …993 both become 9007199254740992.0. The refund for order 101 now matches two merchant rows, and the "lookup" join grows from 3 to 4 rows. Order 101 is paid to HDFC-01 (a merchant who never sold it) and to ICICI-07. Order 103 (ID 42) is fine because small integers are exact in float64, which is why the job "ran fine for months": it only broke once the ID service crossed 2⁵³.

Real-world incident: a settlement or reconciliation job doubles a payout, or silently attributes events to the wrong entity, after an upstream ID space grows. The same thing happens when IDs pass through JSON in JavaScript, through read_csv with a blank cell, or through fillna on a numeric column.

Fixes, in order of strength: - Make it a data contract: IDs are identifiers, not numbers. Carry them as string, or as nullable Int64 / int64[pyarrow], which keep missing values without converting to float (refunds.astype({"merchant_id": "Int64"}) gives [9007199254740993, <NA>, 42] after the join and the payout row count stays 3). - Assert row counts and key dtypes at every join boundary in the pipeline (assert len(payout) == len(report)), and fail the run rather than publish. - Reconcile totals (sum of refunds in == sum of payouts out) before money moves.

Takeaway: a missing value in an int64 column silently turns it into floats, and above 2⁵³ that rewrites your IDs; treat identifiers as strings or nullable integers and assert join cardinality.

Follow-ups:

  1. "Would validate="many_to_one" on the second merge have caught this?" No. In pandas 2.2 it checks that the right keys are unique, and merchants.merchant_id is unique as int64 (…992 and …993 differ), so it passes; the collision only appears after the cross-dtype cast, and the result still has 4 rows. That is why a post-join row-count assertion is the guard you actually want, alongside validate.
  2. "Where else in a Python data stack does this exact bug appear?" read_csv on a column with any blank cell (float64 by default), json.loads is fine in Python but the same payload in a browser or Node loses precision, Parquet written from a float-inferred column, df.loc[mask, 'id'] = np.nan, and pd.concat of a frame where the ID column is missing. Pandas' dtype_backend="numpy_nullable" / "pyarrow" on readers avoids most of them.
  3. "You own this pipeline. What do you change so the next dtype drift fails loudly instead of paying money?" A schema contract checked at ingestion and before publish (column names, dtypes, nullability, key uniqueness), row-count and sum reconciliation between stages, an alert on any join whose output count differs from its left input on a declared many-to-one join, and a quarantine path. Plus a regression test with an ID above 2⁵³ in the fixture data.

Scoring: Strong: Exact output, including the rounding to …992, the 3 -> 4 fan-out and the double payout. Explains the 53-bit mantissa and why it only appeared once IDs grew. Proposes nullable/string IDs as a contract plus join-cardinality assertions, and knows validate= alone does not save you. Acceptable: Predicts float64 and NaN, but needs a prompt to see the precision loss; once told the value changed, reasons to the duplicate match and suggests Int64 or string IDs. Red flag: Expects int64 with NaN, or treats the 4th row as harmless and suggests drop_duplicates() after the join, which would hide which account was wrong.

SR-DS-C01 · Bootstrap CI for revenue per session, resampling users not sessions

Medium · Coding · Senior (5–8 years) · experimentation, bootstrap, ratio metrics, clustered data, numpy vectorisation, reproducibility · 25 min

Problem:

A food-delivery app runs A/B tests on its home feed. The headline metric is revenue per session = total revenue / total sessions in an arm. Randomisation is per user, and a user has many sessions. An analyst's notebook bootstraps the metric by resampling sessions, and its intervals look reassuringly tight; you suspect they are far too narrow.

Write bootstrap_ratio_ci(user_ids, numerator, denominator, *, n_boot=2000, alpha=0.05, seed=0, max_cells=5_000_000) that returns RatioCI(point, low, high):

  • point is the ratio of sums over all rows (not the mean of per-row ratios).
  • The CI is a percentile bootstrap where each replicate resamples users with replacement, carrying all of a user's rows with them.
  • Reproducible: the same inputs and seed give exactly the same result, and the order of rows must not matter.
  • Vectorised with NumPy; memory for the resampling indices must stay around max_cells integers regardless of n_boot.
  • Raise ValueError for mismatched lengths, fewer than 2 users, a user with a non-positive total denominator, or invalid alpha/n_boot.

Signature: def bootstrap_ratio_ci(user_ids, numerator, denominator, *, n_boot: int = 2000, alpha: float = 0.05, seed: int = 0, max_cells: int = 5_000_000) -> RatioCI:

Constraints: Up to 2 million users and 30 million session rows per arm. NumPy only (no SciPy/statsmodels). Use np.random.default_rng(seed), never the global RNG.

Examples:

  • users = ['asha']*100 + ['ravi']*100; revenue = [10.0]*100 + [0.0]*100; sessions = [1]*200 → RatioCI(point=5.0, low=0.0, high=10.0) # only 2 independent units of evidence
  • same data, resampling sessions instead → roughly (4.3, 5.7): treats 200 correlated sessions as 200 independent observations

Hints:

  1. What is the unit of randomisation in this experiment? That's what each bootstrap draw should sample.
  2. Aggregate to one row per user first (np.unique(..., return_inverse=True) + np.bincount(..., weights=...)); then a replicate is just a draw of user indices.
  3. Draw a (k, n_users) matrix of indices per chunk, with k chosen from max_cells, and compute num_u[idx].sum(1) / den_u[idx].sum(1).

Reference solution:

from typing import NamedTuple

import numpy as np


class RatioCI(NamedTuple):
    point: float
    low: float
    high: float


def bootstrap_ratio_ci(user_ids, numerator, denominator, *, n_boot: int = 2000,
                       alpha: float = 0.05, seed: int = 0,
                       max_cells: int = 5_000_000) -> RatioCI:
    """Percentile CI for sum(numerator) / sum(denominator), resampling USERS, not rows.

    Rows (sessions, orders) from one user are correlated; the user is the unit of
    randomisation, so the user is the unit of resampling.
    """
    user_ids = np.asarray(user_ids)
    num = np.asarray(numerator, dtype=float)
    den = np.asarray(denominator, dtype=float)
    if not (len(user_ids) == len(num) == len(den)):
        raise ValueError("user_ids, numerator and denominator must have the same length")
    if not 0 < alpha < 1 or n_boot < 1:
        raise ValueError("need 0 < alpha < 1 and n_boot >= 1")

    _, inv = np.unique(user_ids, return_inverse=True)
    num_u = np.bincount(inv, weights=num)
    den_u = np.bincount(inv, weights=den)
    n_users = len(num_u)
    if n_users < 2:
        raise ValueError("need at least 2 users to bootstrap")
    if np.any(den_u <= 0):
        raise ValueError("every user needs a positive denominator")

    rng = np.random.default_rng(seed)
    chunk = max(1, max_cells // n_users)          # bound memory: chunk x n_users indices
    ratios = np.empty(n_boot)
    for start in range(0, n_boot, chunk):
        k = min(chunk, n_boot - start)
        idx = rng.integers(0, n_users, size=(k, n_users))
        ratios[start:start + k] = num_u[idx].sum(axis=1) / den_u[idx].sum(axis=1)

    low, high = np.quantile(ratios, [alpha / 2, 1 - alpha / 2])
    return RatioCI(float(num_u.sum() / den_u.sum()), float(low), float(high))

Complexity: O(R) to aggregate R rows to U users, then O(n_boot × U) time for the replicates, done in chunks so peak extra memory is O(max_cells): the int64 index matrix plus the two gathered float arrays, about 120 MB at the default, plus O(n_boot) for the replicate ratios. At U = 2 million and n_boot = 2000 that's 4 billion index draws: minutes, not hours, and a good reason to move to the delta method (see alternatives) for routine dashboards.

Tests:

import numpy as np

# Two users, 100 sessions each: Asha spends 10 every session, Ravi never buys.
users = np.array(["asha"] * 100 + ["ravi"] * 100)
revenue = np.array([10.0] * 100 + [0.0] * 100)
sessions = np.ones(200)
ci = bootstrap_ratio_ci(users, revenue, sessions, n_boot=4000, seed=7)
assert ci.point == 5.0
# Resampling users: each replicate is {asha, asha}, {asha, ravi} or {ravi, ravi}.
assert (ci.low, ci.high) == (0.0, 10.0), ci

# Same seed -> identical result; row order does not matter.
again = bootstrap_ratio_ci(users, revenue, sessions, n_boot=4000, seed=7)
assert again == ci
perm = np.random.default_rng(1).permutation(200)
assert bootstrap_ratio_ci(users[perm], revenue[perm], sessions[perm], n_boot=4000, seed=7) == ci

# No between-user variation -> a zero-width interval at the point estimate.
flat = bootstrap_ratio_ci([1, 1, 2, 2, 3], [4, 4, 4, 4, 4], [2, 2, 2, 2, 2], n_boot=300)
assert flat == (2.0, 2.0, 2.0), flat

# Realistic sanity check, and chunking (max_cells forces many small chunks) gives the same
# answer as one big chunk because the RNG stream is consumed in the same order.
rng = np.random.default_rng(42)
n_sessions = rng.poisson(5, size=400) + 1
uid = np.repeat(np.arange(400), n_sessions)
rev = rng.gamma(2.0, 50.0, size=uid.size) * (rng.random(uid.size) < 0.3)
big = bootstrap_ratio_ci(uid, rev, np.ones(uid.size), n_boot=1000, seed=3)
small = bootstrap_ratio_ci(uid, rev, np.ones(uid.size), n_boot=1000, seed=3, max_cells=1000)
assert big.low < big.point < big.high
assert np.allclose(big, small)
# Ratio of sums, not mean of per-row ratios.
assert np.isclose(big.point, rev.sum() / uid.size)

for bad in [dict(user_ids=[1, 2], numerator=[1.0], denominator=[1.0, 1.0]),
            dict(user_ids=[1, 1], numerator=[1.0, 2.0], denominator=[1.0, 1.0]),
            dict(user_ids=[1, 2], numerator=[1.0, 2.0], denominator=[0.0, 1.0])]:
    try:
        bootstrap_ratio_ci(**bad)
    except ValueError:
        pass
    else:
        raise AssertionError(f"accepted {bad}")

A common wrong solution (the tests catch it):

from typing import NamedTuple

import numpy as np


class RatioCI(NamedTuple):
    point: float
    low: float
    high: float


def bootstrap_ratio_ci(user_ids, numerator, denominator, *, n_boot: int = 2000,
                       alpha: float = 0.05, seed: int = 0,
                       max_cells: int = 5_000_000) -> RatioCI:
    user_ids = np.asarray(user_ids)
    num = np.asarray(numerator, dtype=float)
    den = np.asarray(denominator, dtype=float)
    if not (len(user_ids) == len(num) == len(den)):
        raise ValueError("user_ids, numerator and denominator must have the same length")
    if len(np.unique(user_ids)) < 2 or np.any(den <= 0):
        raise ValueError("bad input")
    rng = np.random.default_rng(seed)
    n = len(num)
    chunk = max(1, max_cells // n)
    ratios = np.empty(n_boot)
    for start in range(0, n_boot, chunk):          # resample rows (sessions)
        k = min(chunk, n_boot - start)
        idx = rng.integers(0, n, size=(k, n))
        ratios[start:start + k] = num[idx].sum(axis=1) / den[idx].sum(axis=1)
    low, high = np.quantile(ratios, [alpha / 2, 1 - alpha / 2])
    return RatioCI(float(num.sum() / den.sum()), float(low), float(high))

Alternatives and tradeoffs:

  • Delta method (linearisation of the ratio with user-level sums): a closed-form standard error in O(U), the standard choice for an experimentation platform's daily metrics. Bootstrap stays useful for metrics without a clean variance formula (quantiles, trimmed means) and to validate the delta method.
  • Poisson bootstrap: give each user a Poisson(1) weight per replicate instead of drawing indices. Parallelises and streams (each user is processed independently), which is how large platforms bootstrap over distributed data.
  • Mean of per-user ratios answers a different question (the average user's revenue per session); pick the estimand with the product team before choosing the method.
  • Row-level (session) bootstrap is only valid if sessions are independent, which they almost never are when randomisation is per user.

Follow-ups:

  1. "How would you show a sceptical PM that the analyst's session-level intervals are wrong, without a statistics lecture?" Run an A/A test (or simulate many A/A splits of historical data): with session-level CIs, far more than 5% of A/A comparisons come out 'significant'. With user-level resampling the false-positive rate is close to the nominal 5%. A calibration plot of A/A p-values makes it obvious.
  2. "You need the CI of the difference between treatment and control. How do you extend this?" Resample users independently within each arm (the arms are independent samples), compute the ratio in each, and take the percentile interval of the difference (or the relative lift). Use one Generator per arm seeded from a SeedSequence.spawn so results are reproducible and streams don't overlap.
  3. "A few whale users (bulk caterers) dominate revenue. What happens to the bootstrap and what do you do?" The replicate distribution becomes lumpy and the percentile CI unreliable with few whales. Options: cap/winsorise revenue per user at a pre-registered percentile, analyse them as a separate stratum, or use CUPED with pre-period revenue to remove much of the per-user variance. Decide before looking at results to avoid p-hacking.

Scoring: Strong: Resamples users and can explain why in terms of the unit of randomisation and correlated sessions; aggregates to per-user sums, vectorised replicates in memory-bounded chunks, seeded Generator, ratio of sums. Brings up the delta method and A/A validation without prompting. Acceptable: Correct user-level bootstrap with a Python loop over replicates or unbounded memory; explains the clustering issue once asked about the unit of randomisation. Red flag: Resamples sessions, averages per-session ratios, or uses np.random.seed global state in a library function.

SR-DS-C02 · CUPED variance reduction that survives an unlucky randomisation

Hard · Coding · Senior (5–8 years) · experimentation, CUPED, variance reduction, covariates, numpy, missing data, statistics in code · 30 min

Problem:

Your experimentation library reports A/B results for a grocery app. Tests on order value per user need weeks to reach significance because spend varies hugely between users. You will add CUPED: adjust each user's in-experiment metric y using the same metric from the pre-experiment period x.

Implement cuped(y, x, treated) -> CupedResult where:

  • theta = cov(x, y) / var(x), computed on all users pooled (both arms), ddof=1.
  • adjusted = y - theta * (x - mean(x)), with mean(x) also pooled.
  • effect and se are the difference in means of adjusted (treatment − control) and its standard error sqrt(var_t/n_t + var_c/n_c); also return the unadjusted naive_effect and naive_se, plus theta and the adjusted array (input order).
  • New users have no history: x is NaN for them. Impute with the pooled mean of observed x, so they are left unadjusted. Do not modify the caller's arrays.
  • If x has zero variance, theta = 0.
  • Raise ValueError for shape mismatches, NaN in y, or an arm with fewer than 2 users.

Signature: def cuped(y, x, treated) -> CupedResult:

Constraints: Up to 10 million users per experiment; NumPy only, fully vectorised (no Python loops over users). The result must be identical on reruns: no randomness inside the function.

Examples:

  • 20,000 users; y = 0.8·x + noise + 2·treated; treated users happened to have x about 5 higher → naive_effect ≈ 6 (biased by the pre-period imbalance); effect ≈ 2; se well under half of naive_se; theta ≈ 0.8
  • cuped([1, 2, 3, 4], [5, 5, 5, 5], [T, F, T, F]) → theta = 0.0 and effect == naive_effect == -1.0

Hints:

  1. Why is it legitimate to use pre-period data at all? What property does x have that a post-period covariate wouldn't?
  2. Think about what centring x within each arm would do to the difference in means. What happens to a chance pre-period imbalance?
  3. np.array(x, dtype=float) makes a copy you can impute into; np.asarray may not.

Reference solution:

from dataclasses import dataclass

import numpy as np


@dataclass(frozen=True)
class CupedResult:
    effect: float              # CUPED difference in means (treatment - control)
    se: float
    naive_effect: float
    naive_se: float
    theta: float
    adjusted: np.ndarray       # per-user adjusted metric, same order as the input


def _diff_and_se(values: np.ndarray, treated: np.ndarray) -> tuple[float, float]:
    t, c = values[treated], values[~treated]
    se = np.sqrt(t.var(ddof=1) / t.size + c.var(ddof=1) / c.size)
    return float(t.mean() - c.mean()), float(se)


def cuped(y, x, treated) -> CupedResult:
    """CUPED: y_adj = y - theta * (x - mean(x)), with theta and mean(x) pooled over BOTH arms.

    x is the same metric in the pre-experiment period. NaN in x (new users with no
    history) is imputed with the pooled mean of observed x, i.e. those users are not adjusted.
    """
    y = np.asarray(y, dtype=float)
    x = np.array(x, dtype=float)              # copy: we impute in place below
    treated = np.asarray(treated, dtype=bool)
    if not (y.shape == x.shape == treated.shape) or y.ndim != 1:
        raise ValueError("y, x and treated must be 1-D arrays of the same length")
    if treated.sum() < 2 or (~treated).sum() < 2:
        raise ValueError("each arm needs at least 2 users")
    if np.isnan(y).any():
        raise ValueError("y must not contain NaN")

    observed = ~np.isnan(x)
    x_mean = x[observed].mean() if observed.any() else 0.0
    x[~observed] = x_mean

    var_x = x.var(ddof=1)
    theta = 0.0 if var_x == 0 else float(np.cov(x, y, ddof=1)[0, 1] / var_x)
    adjusted = y - theta * (x - x_mean)

    effect, se = _diff_and_se(adjusted, treated)
    naive_effect, naive_se = _diff_and_se(y, treated)
    return CupedResult(effect, se, naive_effect, naive_se, theta, adjusted)

Complexity: O(n) time and O(n) memory: a few passes over the arrays for means, variance and covariance, and one adjusted copy. No sorting, no resampling.

Tests:

import numpy as np

rng = np.random.default_rng(2026)
n = 20_000
treated = np.arange(n) % 2 == 0
# An unlucky randomisation: treated users happened to spend ~5 more before the test.
x = rng.normal(100, 30, n) + np.where(treated, 5.0, 0.0)
y = 0.8 * x + rng.normal(0, 10, n) + np.where(treated, 2.0, 0.0)   # true effect = 2.0

r = cuped(y, x, treated)
assert abs(r.naive_effect - 6.0) < 1.0, r.naive_effect      # biased by the imbalance
assert abs(r.effect - 2.0) < 0.5, r.effect                  # CUPED recovers ~2
assert r.se < 0.5 * r.naive_se, (r.se, r.naive_se)          # and is much more precise
assert abs(r.theta - 0.8) < 0.05, r.theta
assert np.isclose(r.adjusted.mean(), y.mean())              # pooled centring keeps the grand mean

# New users without history: imputed, not adjusted, and the caller's array is untouched.
x_missing = x.copy()
x_missing[:100] = np.nan
before = x_missing.copy()
r2 = cuped(y, x_missing, treated)
assert np.array_equal(np.isnan(x_missing), np.isnan(before))
assert np.allclose(r2.adjusted[:100], y[:100])
assert abs(r2.effect - 2.0) < 0.5

# A constant covariate carries no information: no adjustment at all.
r3 = cuped([1.0, 2.0, 3.0, 4.0], [5.0, 5.0, 5.0, 5.0], [True, False, True, False])
assert r3.theta == 0.0 and r3.effect == r3.naive_effect == -1.0

for args in [([1.0, 2.0], [1.0, 2.0], [True, True]),
             ([1.0, 2.0, 3.0], [1.0, 2.0], [True, False, True]),
             ([1.0, np.nan, 3.0, 4.0], [1.0, 2.0, 3.0, 4.0], [True, False, True, False])]:
    try:
        cuped(*args)
    except ValueError:
        pass
    else:
        raise AssertionError(f"accepted {args}")

A common wrong solution (the tests catch it):

from dataclasses import dataclass

import numpy as np


@dataclass(frozen=True)
class CupedResult:
    effect: float              # CUPED difference in means (treatment - control)
    se: float
    naive_effect: float
    naive_se: float
    theta: float
    adjusted: np.ndarray       # per-user adjusted metric, same order as the input


def _diff_and_se(values: np.ndarray, treated: np.ndarray) -> tuple[float, float]:
    t, c = values[treated], values[~treated]
    se = np.sqrt(t.var(ddof=1) / t.size + c.var(ddof=1) / c.size)
    return float(t.mean() - c.mean()), float(se)


def cuped(y, x, treated) -> CupedResult:
    """CUPED: y_adj = y - theta * (x - mean(x)), with theta and mean(x) pooled over BOTH arms.

    x is the same metric in the pre-experiment period. NaN in x (new users with no
    history) is imputed with the pooled mean of observed x, i.e. those users are not adjusted.
    """
    y = np.asarray(y, dtype=float)
    x = np.array(x, dtype=float)              # copy: we impute in place below
    treated = np.asarray(treated, dtype=bool)
    if not (y.shape == x.shape == treated.shape) or y.ndim != 1:
        raise ValueError("y, x and treated must be 1-D arrays of the same length")
    if treated.sum() < 2 or (~treated).sum() < 2:
        raise ValueError("each arm needs at least 2 users")
    if np.isnan(y).any():
        raise ValueError("y must not contain NaN")

    observed = ~np.isnan(x)
    x_mean = x[observed].mean() if observed.any() else 0.0
    x[~observed] = x_mean

    var_x = x.var(ddof=1)
    theta = 0.0 if var_x == 0 else float(np.cov(x, y, ddof=1)[0, 1] / var_x)
    arm_mean = np.where(treated, x[treated].mean(), x[~treated].mean())
    adjusted = y - theta * (x - arm_mean)          # centre within each arm

    effect, se = _diff_and_se(adjusted, treated)
    naive_effect, naive_se = _diff_and_se(y, treated)
    return CupedResult(effect, se, naive_effect, naive_se, theta, adjusted)

Alternatives and tradeoffs:

  • Regression adjustment / ANCOVA: regress y on treated and x; the treatment coefficient is the CUPED estimate (with slightly different theta). It generalises to several covariates and interactions (treated × x), and gives a standard error directly; CUPED is the same idea in two NumPy lines that are easy to run at scale.
  • Per-arm theta is sometimes used when the treatment changes the x–y relationship; pooled theta is the conventional default and keeps the estimator simple to explain.
  • Stratification on pre-period spend buckets reduces variance too and handles non-linear relationships, at the cost of choosing buckets.
  • CUPAC: use a model's prediction of y from pre-period features as the covariate; more variance reduction, but the model must use pre-treatment data only.

Follow-ups:

  1. "A colleague 'improves' CUPED by centring x within each arm. The variance still drops. What's wrong?" Centring per arm subtracts each arm's own mean of x, so the arm means of adjusted equal the arm means of y: the effect estimate is exactly the naive one, chance imbalance included. Only the within-arm noise is removed, so the SE shrinks while the bias stays: a confidently wrong result. Pooled centring is what lets CUPED correct imbalance.
  2. "Could you use 'number of sessions during the experiment' as the covariate? It correlates 0.9 with order value." No. A covariate measured after assignment can be affected by the treatment, which biases the estimate (you partly adjust away the effect). Only pre-treatment covariates, or ones provably unaffected by treatment, are safe.
  3. "How do you validate this before it goes into the platform?" Run it on historical A/A splits: the effect should be centred on zero, the 95% CI should cover zero ~95% of the time, and the SE should drop by about 1 - corr(x, y)². Also inject a known synthetic lift into A/A data and check it's recovered without bias. Log theta and the variance reduction per experiment for monitoring.

Scoring: Strong: Pooled theta and pooled centring with a clear reason; vectorised; imputes without mutating input; can explain why per-arm centring re-introduces the imbalance and why post-treatment covariates are forbidden; proposes A/A validation. Acceptable: Correct formula and code, but treats NaN handling or the pooled-vs-per-arm choice as arbitrary until asked to reason about a chance imbalance. Red flag: Uses an in-experiment covariate, drops new users silently, or judges the method only by how much the variance went down.

SR-DS-D01 · An experiment-analysis library and daily pipeline for 40 concurrent A/B tests

Medium · Design · Senior (5–8 years) · experimentation, metric definitions, SRM, variance reduction, sequential testing, library design, reproducibility, testing statistics code · 40 min

Scenario:

A food-delivery app runs about 40 A/B tests at any time (ranking, pricing, UI). Today each data scientist analyses their own test in a notebook: they write their own SQL for "orders per user", pick their own outlier rules, and post a screenshot in Slack. Last quarter two analyses of the same test reached opposite conclusions, a pricing test was stopped early on day 3 "because it was significant", and nobody noticed that a test had assigned 52% of users to treatment instead of 50% because of a bug in the app's assignment code.

You are the senior DS asked to build the shared analysis layer: a Python library plus a daily pipeline that produces a standard scorecard for every running experiment, which DS can extend for deep dives. Assignment logs and events are in the warehouse (~25 million users, ~150 million events/day). Design it.

Clarifying questions:

  • What is the unit of randomisation: user, device, session, or city (for marketplace tests where treatment spills over to riders and restaurants)? (User for most; city-level switchback tests for pricing.)
  • Where do exposures come from: assignment at app open, or a logged exposure when the user actually sees the feature? (Both exist; exposure logging is inconsistent across teams.)
  • Which decisions are made from the scorecard: ship/no-ship by product managers, or input to a DS who decides? How fast do they need results?
  • Which metrics are company-wide guardrails (cancellations, delivery time, refunds) that every test must report?
  • Is there an existing metrics layer (dbt, a semantic layer) or are metrics defined only in notebooks?
  • What compute is available: warehouse SQL only, or Python jobs with enough memory to load per-user aggregates for a test (25M rows × a few columns)?

Reference design:

1. One definition of each metric, versioned. A metrics package where each metric is declared once as data: numerator, denominator, unit of analysis, filters, winsorisation/capping rule, and owner, e.g. orders_per_user = Ratio(num=orders.count, den=users.exposed, cap=p99.9). Definitions compile to warehouse SQL that produces per-unit aggregates (one row per user per experiment arm); statistics run in Python on those aggregates. Metric changes go through PR review and carry a version that every scorecard records.

2. Exposure and assignment as first-class inputs. The pipeline joins events only from each user's first exposure onward (pre-exposure events dilute the effect), restricted to the experiment window, and keeps the assignment table as the source of truth for arms. Users exposed to both arms (a known app bug class) are counted and reported, not silently dropped.

3. Trust checks before results. Every scorecard starts with: sample ratio mismatch (chi-square test of observed vs configured split, alarm at p < 0.001, results hidden behind a warning), pre-period balance on key metrics, exposure counts over time, and data-freshness checks. A failed SRM blocks the ship decision until explained; that would have caught the 52/48 bug on day one.

4. Statistics, chosen per metric type. Ratio metrics with user-level randomisation use the delta method on per-user sums (correct standard errors without resampling); CUPED with pre-period values of the same metric for variance reduction; cluster-robust standard errors or a switchback analysis for city-level tests. Multiple comparisons: one pre-registered primary metric; secondary and guardrail metrics are labelled as such, with a false-discovery-rate correction for exploratory slices.

5. Peeking, handled honestly. PMs will look daily, so the scorecard uses an always-valid / sequential method (e.g. mSPRT or group-sequential boundaries with alpha spending) for the primary metric, and shows a "not yet conclusive" state until the planned sample size or the boundary is reached. Day-3 "significance" is no longer a stopping rule.

6. Library design for DS users. A small, typed API: Experiment.load(id, as_of=...), scorecard(exp, metrics=...), analyse(per_unit_df, metric, method=...) returning a frozen result object (estimate, CI, p-value, method, metric version, data snapshot id). Pure functions over DataFrames/NumPy arrays so deep dives in notebooks use exactly the code the pipeline uses. Deterministic: seeds via np.random.default_rng where resampling is used, and results keyed by (experiment, metric version, code version, data snapshot) so any number can be reproduced.

7. Testing a statistics library. Unit tests against hand-computed cases; simulation tests in CI on synthetic A/A data (false-positive rate ≈ alpha, CI coverage ≈ 95%) and on injected lifts (bias ≈ 0, power as expected); regression tests that pin scorecard numbers for a frozen historical experiment.

8. Delivery. A daily Airflow/Dagster job runs SQL aggregates then the Python analysis per experiment (parallel per experiment, each fits in memory as per-user aggregates), writes results to a scorecards table, and a dashboard renders them. Alerts go to the experiment owner on SRM, guardrail regressions and data delays.

Trade-offs:

  • Delta method vs bootstrap. Delta method is O(n) and closed-form, ideal for a daily job across 40 tests × 30 metrics; bootstrap is general (quantiles, trimmed means) but expensive. Default to delta method, keep bootstrap for special metrics and for validating the delta method.
  • Sequential tests vs fixed-horizon. Sequential testing permits peeking but needs more data to reach the same power. Since people will peek, the honest choice is sequential for the primary metric and fixed-horizon for post-hoc analysis.
  • Centralised definitions vs analyst freedom. A single metric definition prevents contradictory answers; but deep dives need flexibility. The library exposes the same primitives so custom analyses reuse vetted statistics even when the metric is new.
  • SQL vs Python for the heavy lifting. Aggregating 150M events/day belongs in the warehouse; the statistics need a few columns per user and belong in Python where they can be unit-tested. Pushing statistics into SQL makes them hard to test.

Deep-dive probes:

  • "A PM wants the result for 'users in Tier-2 cities on Android' because the overall result is flat." Look for: slicing as exploratory with FDR control or a pre-registered hypothesis, wider intervals for small segments, and a warning that a post-hoc slice is a hypothesis for the next test, not a ship decision.
  • "The new ranking model increases orders per user but delivery time p90 worsens by 40 seconds." Look for: guardrails with pre-agreed thresholds, quantile metrics needing bootstrap or specialised methods, and an explicit decision framework (trade-off owner) rather than a statistics answer.
  • "A pricing test randomised by user shows a huge lift, but riders were shared between arms." Look for: interference (SUTVA violation) in a marketplace, switchback or geo-randomised designs, and analysis with clustered standard errors at the randomisation unit.
  • "Two years later, finance asks why the scorecard for test #418 says +2.1% and a re-run says +1.7%." Look for: versioned metric definitions, data snapshots or as-of queries, code version recorded in results, and late-arriving events (refunds) explained by an analysis cut-off date.

Rubric: Strong: Centralised, versioned metric definitions; analysis from first exposure on per-unit aggregates; SRM and balance checks gating results; delta method + CUPED with clustering at the randomisation unit; a principled answer to peeking; library API that notebooks and pipeline share; simulation-based tests (A/A false-positive rate, coverage). Recognises marketplace interference. Acceptable: Standard metrics, t-tests on per-user aggregates and a daily scorecard with SRM, but treats peeking or multiple comparisons lightly, or needs prompting on reproducibility and testing. Red flag: Analyses event-level rows as independent observations, lets each analyst define metrics ad hoc, ignores SRM, or treats the first day a p-value dips under 0.05 as the result.

SR-DS-O01 · Point-in-time joins with merge_asof leak a same-timestamp score

Medium & Tricky · Output · Senior (5–8 years) · pandas merge_asof, point-in-time correctness, label leakage, feature staleness, training-serving skew · 8 min

Question: You are reviewing the training-set builder for a credit-line model at a lending company. Each application is joined to the customer's bureau score "as of" the application time. The score table gets a new row whenever the bureau sends an update; the row for Asha at exactly 11:00 was written by the hard-inquiry pull that her own application triggered. At serving time the feature store only returns values older than 30 days as missing. What does this print?

import warnings

import pandas as pd

warnings.simplefilter("ignore", DeprecationWarning)  # pandas 2.2 + NumPy 2.x internal noise

apps = pd.DataFrame({
    "customer": ["asha", "ravi", "meera"],
    "applied_at": pd.to_datetime(["2026-03-10 11:00", "2026-03-12 09:00", "2026-03-05 10:00"]),
})
scores = pd.DataFrame({
    "customer": ["asha", "asha", "ravi", "ravi", "meera"],
    "scored_at": pd.to_datetime(["2026-03-01 00:00", "2026-03-10 11:00",
                                 "2026-03-02 00:00", "2026-03-14 00:00",
                                 "2026-01-02 00:00"]),
    "score": [720, 610, 680, 540, 700],
})

apps = apps.sort_values("applied_at")
scores = scores.sort_values("scored_at")

default = pd.merge_asof(apps, scores, left_on="applied_at", right_on="scored_at", by="customer")
print(default[["customer", "score"]].to_string(index=False))

strict = pd.merge_asof(apps, scores, left_on="applied_at", right_on="scored_at",
                       by="customer", allow_exact_matches=False,
                       tolerance=pd.Timedelta(days=30))
print(strict[["customer", "score"]].to_string(index=False))

try:
    pd.merge_asof(apps.sort_values("customer"), scores, left_on="applied_at",
                  right_on="scored_at", by="customer")
except ValueError as exc:
    print("ValueError:", exc)

Expected output:

customer  score
   meera    700
    asha    610
    ravi    680
customer  score
   meera    NaN
    asha  720.0
    ravi  680.0
ValueError: left keys must be sorted

The trap: "Asha gets 720 in the first table" (the candidate reads "as of" as strictly before), and "the rows come back in the original asha, ravi, meera order." Some also expect the last call to work because by="customer" "groups first, so the global sort shouldn't matter."

Explanation:

merge_asof is a nearest-key join. For each left row it takes the last right row (same by group) whose key is less than or equal to the left key: direction="backward" and allow_exact_matches=True are the defaults.

  • Default join. Meera's application on 5 March picks up a score from 2 January, 62 days old. Asha's application at 11:00 matches the score written at exactly 11:00, which already contains the hard inquiry caused by this application: 610 instead of 720. Ravi correctly gets 680; the 14 March score is in the future and is excluded. The rows come back in the left frame's order, which is now sorted by applied_at.
  • Strict join. allow_exact_matches=False makes the match strictly earlier, so Asha gets 720, which is what the decision engine actually saw. tolerance=30 days drops Meera's stale score to NaN, matching what the feature store returns at serving time. The column becomes float64 because of the NaN.
  • Sort requirement. The on keys must be globally sorted in both frames, even with by. Sorting the left frame by customer raises ValueError: left keys must be sorted.

Why this matters at senior level: the model trained on the default join learns from a feature it will never have at decision time (Asha's post-inquiry 610) and from stale values that serving would have blanked (Meera's 700). Offline AUC looks better than production; the gap shows up weeks later as a training-serving skew incident. The fix is a written point-in-time rule shared by training and serving: features must be strictly before the decision timestamp, using the time the value became available (ingestion time), not the time it describes, and with the same staleness TTL as the online store.

Takeaway: merge_asof includes exact-time matches and any age of value by default; set allow_exact_matches=False and a tolerance that mirrors serving, and join on availability time.

Follow-ups:

  1. "The bureau file for 1 March arrives on 3 March but is stamped scored_at = 2026-03-01. What goes wrong and how do you fix the join?" Applications on 1–2 March get a score that didn't exist yet: leakage through event time vs availability time. Store an available_at (ingestion timestamp) and join on that; keep scored_at as a feature if useful. Backfilled history must also carry the real arrival time, not the business date.
  2. "How would you test that a training-set builder is point-in-time correct?" Property-style test: for every joined row assert available_at < decision_time and decision_time - available_at <= ttl; plus a fixture with a same-timestamp row and a future row like this one. Also compare a sample of training features against what the online store logged at decision time (feature logging) and alert on mismatch rate.
  3. "This runs on 200 million applications. merge_asof needs a global sort; what are your options?" Partition by customer hash and sort within partitions (the by key keeps matches inside a partition), or do it in the warehouse with a window/ASOF JOIN (DuckDB, Snowflake, Spark's range joins). In pandas, process per month of applications with the score table filtered to [month_start - ttl, month_end) so each chunk fits in memory.

Scoring: Strong: All three blocks exactly, including row order, Asha's 610 and Meera's NaN. Names it label leakage plus training-serving skew, and fixes it with strict matches, a serving-equivalent TTL and availability timestamps, not just a flag change. Acceptable: Gets the default backward match and the sort error, misses that exact matches are included; once shown, explains why 610 is leakage and proposes allow_exact_matches=False. Red flag: Thinks an asof join is inherently leak-free, or proposes a random train/test split as the leakage fix.

SR-MLE-C01 · Async micro-batcher in front of a blocking model

Hard · Coding · Senior (5–8 years) · model serving, asyncio, dynamic batching, asyncio.to_thread, cancellation, failure isolation, graceful shutdown · 35 min

Problem:

A FastAPI service scores product-search queries with a ranking model. Each request carries one query, but the model's predict_batch is ~10× cheaper per item at batch size 32 than at batch size 1. Build the dynamic batching layer that sits between request handlers and the model.

MicroBatcher(predict_batch, *, max_batch_size=32, max_wait_s=0.005):

  • await submit(item) returns that item's prediction. Concurrent submits are grouped into one predict_batch(list_of_items) call of up to max_batch_size items; a partial batch is sent once its first item has waited max_wait_s.
  • predict_batch is a blocking function (NumPy / framework inference). It must not run on the event-loop thread.
  • If predict_batch raises, or returns a different number of results than inputs, every caller in that batch gets an exception, and the batcher keeps serving later requests.
  • A caller that is cancelled (client disconnected, request timeout) must not crash the worker or affect other callers; a cancelled caller's item should not be sent to the model if it hasn't been yet.
  • await close() stops accepting new work (submit then raises RuntimeError), finishes everything already queued, and stops the background worker.
  • Invalid settings (max_batch_size < 1, max_wait_s < 0) raise ValueError.

Signature: class MicroBatcher: def __init__(self, predict_batch: Callable[[list[Any]], Sequence[Any]], *, max_batch_size: int = 32, max_wait_s: float = 0.005) -> None: ... async def submit(self, item: Any) -> Any: ... async def close(self) -> None: ...

Constraints: Python 3.12, standard library asyncio only. One batcher per model per process; it is created inside the running event loop (e.g. in FastAPI's lifespan). Results must go back to the right caller, in the right order.

Examples:

  • 20 concurrent submit(i) with max_batch_size=8, predict_batch = lambda xs: [2*x for x in xs] → results [0, 2, ..., 38]; predict_batch called with batches of 8, 8, 4
  • a single submit(21) with max_wait_s=0.02 → 42, after about 20 ms, as a batch of 1
  • a batch containing an input that makes predict_batch raise ValueError → every caller in that batch gets the ValueError; the next submit works normally

Hints:

  1. Pair each item with an asyncio.Future the caller awaits; a single background task owns the queue and resolves the futures.
  2. Collect a batch as: block for the first item, then drain with get_nowait(), then wait for more only until a deadline computed from the first item's arrival.
  3. What happens to the futures still pending if predict_batch raises inside the worker task and nothing catches it? And what does set_result do on a future whose caller was cancelled?

Reference solution:

import asyncio
from collections.abc import Callable, Sequence
from typing import Any


class MicroBatcher:
    """Coalesce concurrent single-item predictions into batched model calls.

    `predict_batch` is a *blocking* function (NumPy / framework inference) and runs in a
    worker thread so the event loop keeps accepting requests.
    """

    def __init__(self, predict_batch: Callable[[list[Any]], Sequence[Any]], *,
                 max_batch_size: int = 32, max_wait_s: float = 0.005) -> None:
        if max_batch_size < 1 or max_wait_s < 0:
            raise ValueError("max_batch_size >= 1 and max_wait_s >= 0 required")
        self._predict = predict_batch
        self._max_batch = max_batch_size
        self._max_wait = max_wait_s
        self._queue: asyncio.Queue[tuple[Any, asyncio.Future]] = asyncio.Queue()
        self._worker: asyncio.Task | None = None
        self._closed = False

    async def submit(self, item: Any) -> Any:
        if self._closed:
            raise RuntimeError("batcher is closed")
        if self._worker is None:
            self._worker = asyncio.create_task(self._run(), name="micro-batcher")
        fut = asyncio.get_running_loop().create_future()
        self._queue.put_nowait((item, fut))
        return await fut

    async def close(self) -> None:
        """Stop accepting work, finish everything already queued, stop the worker."""
        self._closed = True
        if self._worker is not None:
            await self._queue.join()
            self._worker.cancel()
            try:
                await self._worker
            except asyncio.CancelledError:
                pass

    async def _collect(self) -> list[tuple[Any, asyncio.Future]]:
        loop = asyncio.get_running_loop()
        batch = [await self._queue.get()]
        deadline = loop.time() + self._max_wait
        while len(batch) < self._max_batch:
            try:
                batch.append(self._queue.get_nowait())
                continue
            except asyncio.QueueEmpty:
                pass
            remaining = deadline - loop.time()
            if remaining <= 0:
                break
            try:
                async with asyncio.timeout(remaining):
                    batch.append(await self._queue.get())
            except TimeoutError:
                break
        return batch

    async def _run(self) -> None:
        while True:
            batch = await self._collect()
            try:
                live = [(item, fut) for item, fut in batch if not fut.done()]  # skip cancelled callers
                if live:
                    await self._dispatch(live)
            finally:
                for _ in batch:
                    self._queue.task_done()

    async def _dispatch(self, live: list[tuple[Any, asyncio.Future]]) -> None:
        items = [item for item, _ in live]
        try:
            results = await asyncio.to_thread(self._predict, items)
            if len(results) != len(items):
                raise ValueError(f"predict_batch returned {len(results)} results for {len(items)} inputs")
        except Exception as exc:                      # one bad batch must not kill the worker
            for _, fut in live:
                if not fut.done():
                    fut.set_exception(exc)
            return
        for (_, fut), result in zip(live, results):
            if not fut.done():
                fut.set_result(result)

Complexity: Per request: O(1) queue operations plus its share of one model call. Added latency is at most max_wait_s plus queueing behind in-flight batches; throughput is bounded by predict_batch time per batch, because this design runs one batch at a time per batcher. Memory is O(queued requests), which is unbounded here: see the follow-up on backpressure.

Tests:

import asyncio

batches: list[list[int]] = []


def double(xs: list[int]) -> list[int]:
    batches.append(list(xs))
    if -1 in xs:
        raise ValueError("bad feature vector")
    if -2 in xs:
        return [0]                                    # wrong length
    return [x * 2 for x in xs]


async def main() -> None:
    b = MicroBatcher(double, max_batch_size=8, max_wait_s=0.02)

    # 20 concurrent requests -> 3 model calls, every caller gets its own answer.
    async with asyncio.timeout(2):
        results = await asyncio.gather(*(b.submit(i) for i in range(20)))
    assert results == [i * 2 for i in range(20)], results
    assert [len(x) for x in batches] == [8, 8, 4], batches

    # A lone request is not held forever: it goes out after max_wait as a batch of 1.
    batches.clear()
    async with asyncio.timeout(2):
        assert await b.submit(21) == 42
    assert batches == [[21]]

    # A failing batch fails every caller in it, and the worker survives.
    async with asyncio.timeout(2):
        out = await asyncio.gather(b.submit(1), b.submit(-1), b.submit(3), return_exceptions=True)
    assert all(isinstance(o, ValueError) for o in out), out
    async with asyncio.timeout(2):
        out = await asyncio.gather(b.submit(5), b.submit(-2), return_exceptions=True)
    assert all(isinstance(o, ValueError) and "results for 2 inputs" in str(o) for o in out), out
    async with asyncio.timeout(2):
        assert await b.submit(7) == 14

    # A caller that gives up (client disconnect) doesn't break the others.
    batches.clear()
    gone = asyncio.create_task(b.submit(100))
    stay = asyncio.create_task(b.submit(200))
    await asyncio.sleep(0)                            # both are queued now
    gone.cancel()
    async with asyncio.timeout(2):
        assert await stay == 400
    assert batches == [[200]], batches

    # close() drains queued work, then refuses new work.
    pending = [asyncio.create_task(b.submit(i)) for i in (30, 31, 32)]
    await asyncio.sleep(0)
    async with asyncio.timeout(2):
        await b.close()
    assert [p.result() for p in pending] == [60, 62, 64]
    try:
        await b.submit(1)
    except RuntimeError:
        pass
    else:
        raise AssertionError("submit after close accepted")


asyncio.run(main())

for bad in [dict(max_batch_size=0), dict(max_wait_s=-1)]:
    try:
        MicroBatcher(double, **bad)
    except ValueError:
        pass
    else:
        raise AssertionError(bad)

A common wrong solution (the tests catch it):

import asyncio
from collections.abc import Callable, Sequence
from typing import Any


class MicroBatcher:
    """Coalesce concurrent single-item predictions into batched model calls.

    `predict_batch` is a *blocking* function (NumPy / framework inference) and runs in a
    worker thread so the event loop keeps accepting requests.
    """

    def __init__(self, predict_batch: Callable[[list[Any]], Sequence[Any]], *,
                 max_batch_size: int = 32, max_wait_s: float = 0.005) -> None:
        if max_batch_size < 1 or max_wait_s < 0:
            raise ValueError("max_batch_size >= 1 and max_wait_s >= 0 required")
        self._predict = predict_batch
        self._max_batch = max_batch_size
        self._max_wait = max_wait_s
        self._queue: asyncio.Queue[tuple[Any, asyncio.Future]] = asyncio.Queue()
        self._worker: asyncio.Task | None = None
        self._closed = False

    async def submit(self, item: Any) -> Any:
        if self._closed:
            raise RuntimeError("batcher is closed")
        if self._worker is None:
            self._worker = asyncio.create_task(self._run(), name="micro-batcher")
        fut = asyncio.get_running_loop().create_future()
        self._queue.put_nowait((item, fut))
        return await fut

    async def close(self) -> None:
        """Stop accepting work, finish everything already queued, stop the worker."""
        self._closed = True
        if self._worker is not None:
            await self._queue.join()
            self._worker.cancel()
            try:
                await self._worker
            except asyncio.CancelledError:
                pass

    async def _collect(self) -> list[tuple[Any, asyncio.Future]]:
        loop = asyncio.get_running_loop()
        batch = [await self._queue.get()]
        deadline = loop.time() + self._max_wait
        while len(batch) < self._max_batch:
            try:
                batch.append(self._queue.get_nowait())
                continue
            except asyncio.QueueEmpty:
                pass
            remaining = deadline - loop.time()
            if remaining <= 0:
                break
            try:
                async with asyncio.timeout(remaining):
                    batch.append(await self._queue.get())
            except TimeoutError:
                break
        return batch

    async def _run(self) -> None:
        while True:
            batch = await self._collect()
            try:
                live = [(item, fut) for item, fut in batch if not fut.done()]  # skip cancelled callers
                if live:
                    await self._dispatch(live)
            finally:
                for _ in batch:
                    self._queue.task_done()

    async def _dispatch(self, live: list[tuple[Any, asyncio.Future]]) -> None:
        items = [item for item, _ in live]
        results = await asyncio.to_thread(self._predict, items)
        if len(results) != len(items):
            raise ValueError(f"predict_batch returned {len(results)} results for {len(items)} inputs")
        for (_, fut), result in zip(live, results):
            if not fut.done():
                fut.set_result(result)

Alternatives and tradeoffs:

  • Serving frameworks (Triton, TorchServe, Ray Serve's @serve.batch, BentoML adaptive batching) implement this with tuned schedulers; know what they do before choosing to hand-roll. Hand-rolling makes sense when the model is a NumPy/LightGBM call inside an existing FastAPI service.
  • Several batches in flight: dispatch each batch as its own task (bounded by a semaphore) to overlap model calls when the model releases the GIL or runs on a GPU stream; this improves throughput at the cost of ordering and memory.
  • Process pool instead of to_thread when inference is pure-Python and holds the GIL; costs pickling of inputs and outputs per batch.
  • Adaptive max_wait: wait less under low load (latency) and fill batches under high load (throughput), e.g. dispatch immediately when the model is idle.

Follow-ups:

  1. "Traffic spikes to 3× what the model can process. What happens with this design, and what would you add?" The queue grows without bound: latency climbs for everyone, memory grows, and requests time out after the model has already spent work on them. Add a bounded queue (or a max-queued counter) and reject with 429/503 when full, propagate a per-request deadline and drop items whose deadline already passed before batching, and autoscale on queue depth rather than CPU.
  2. "How do you choose max_batch_size and max_wait_s for a p99 latency SLO of 50 ms?" Measure model latency as a function of batch size (it's usually flat-ish then linear), then load-test: p99 ≈ max_wait + queueing + model time at the typical batch size. Pick the batch size at the knee of the throughput curve, and max_wait as a small fraction of the budget (a few ms). Export batch-size and wait-time histograms so you can see whether batches actually fill in production.
  3. "Every caller in a failed batch got the error, but only one input was bad. Can you do better?" On failure, bisect: retry the batch split in halves (or item by item) to isolate the poison input, fail only that caller, and log it with a request ID. Cap the retries so a model outage doesn't multiply load. Validating inputs (shape, dtype, NaN) before batching prevents most of these.

Scoring: Strong: Future-per-request with a single owner task, deadline-based batch collection, model call in a thread, failure fan-out that keeps the worker alive, guards against cancelled futures, clean close() that drains. Raises backpressure and SLO-based tuning unprompted. Acceptable: Batches correctly and returns results to the right callers, but the worker dies on a model exception or crashes on a cancelled caller; fixes it once the failure scenario is described. Red flag: Calls the blocking model directly on the event loop, batches with a fixed sleep per request, or loses the mapping between inputs and callers.

SR-MLE-C02 · A PSI drift monitor that doesn't go blind outside the training range

Medium · Coding · Senior (5–8 years) · drift monitoring, PSI, numpy searchsorted, quantile binning, missing values, model observability · 25 min

Problem:

A credit-risk model in production uses monthly_income (rupees) as a feature. Last quarter an upstream team started sending the value in paise for one partner channel; the model's approval rate shifted for two weeks before anyone noticed, even though a drift dashboard existed. Its PSI stayed under 0.01 the whole time.

Write PsiMonitor(reference, n_bins=10, eps=1e-4) with psi(live) -> float:

  • Bins come from the reference (training) data: the interior quantile cut points at 1/n_bins, 2/n_bins, …; duplicate cut points (heavily tied data) collapse, so n_bins_effective may be smaller than n_bins.
  • The outermost bins are open-ended: any live value below the first cut or above the last cut still lands in a bin.
  • Missing values (NaN) get their own bin, in both reference and live data, so a null spike is drift.
  • Proportions are clipped below at eps before taking logs, so an empty bin can't produce inf.
  • PSI = Σ (actual − expected) · ln(actual / expected) over all bins.
  • Raise ValueError for an all-missing reference, n_bins < 2, or an empty live sample.

Signature: class PsiMonitor: def __init__(self, reference, n_bins: int = 10, eps: float = 1e-4) -> None: ... @property def n_bins_effective(self) -> int: ... def psi(self, live) -> float: ...

Constraints: NumPy only, vectorised: the live sample can be several million rows per hour, across ~300 features. The reference is fitted once at training time and shipped with the model artifact.

Examples:

  • reference ~ N(50,000, 15,000); live from the same distribution → psi < 0.02
  • live = half normal values, half 10× the training max (rupees sent as paise) → psi > 1.0 (a min/max histogram would silently drop the second half and report ≈ 0.001)
  • live with 30% NaN, rest normal → psi > 0.25

Hints:

  1. np.histogram(values, bins=edges) ignores values outside [edges[0], edges[-1]]. What would that do here?
  2. Store only the interior cut points and use np.searchsorted(cuts, values, side='right'): indices 0..len(cuts) cover the whole real line.
  3. Count missing values separately and append them as one more bin before normalising by the total sample size.

Reference solution:

import numpy as np


class PsiMonitor:
    """Population Stability Index of a live feature sample against its training reference.

    Bins are quantile cut points of the reference, open-ended at both ends, plus a
    dedicated bin for missing values, so out-of-range values and null spikes count.
    """

    def __init__(self, reference, n_bins: int = 10, eps: float = 1e-4) -> None:
        ref = np.asarray(reference, dtype=float)
        observed = ref[~np.isnan(ref)]
        if observed.size == 0:
            raise ValueError("reference has no non-missing values")
        if n_bins < 2:
            raise ValueError("n_bins must be >= 2")
        qs = np.linspace(0, 1, n_bins + 1)[1:-1]
        self.cuts = np.unique(np.quantile(observed, qs))   # ties collapse duplicate cuts
        self.eps = eps
        self.expected = self._proportions(ref)

    @property
    def n_bins_effective(self) -> int:
        return len(self.cuts) + 1                           # excluding the missing bin

    def _proportions(self, values: np.ndarray) -> np.ndarray:
        if values.size == 0:
            raise ValueError("no values to compare")
        missing = np.isnan(values)
        idx = np.searchsorted(self.cuts, values[~missing], side="right")
        counts = np.bincount(idx, minlength=len(self.cuts) + 1).astype(float)
        counts = np.append(counts, missing.sum())
        return np.maximum(counts / values.size, self.eps)   # empty bins would make log() blow up

    def psi(self, live) -> float:
        actual = self._proportions(np.asarray(live, dtype=float))
        return float(np.sum((actual - self.expected) * np.log(actual / self.expected)))

Complexity: Fitting: O(n log n) for the reference quantiles, done once. Each psi call is O(m log b) for m live values and b bins (searchsorted) plus O(m) for bincount, with O(b) state. Millions of rows per feature take milliseconds.

Tests:

import numpy as np

rng = np.random.default_rng(11)
train = rng.normal(50_000, 15_000, 50_000)          # monthly income feature, in rupees
mon = PsiMonitor(train, n_bins=10)
assert mon.n_bins_effective == 10
assert mon.psi(train) == 0.0
assert mon.psi(rng.normal(50_000, 15_000, 20_000)) < 0.02       # same distribution
assert mon.psi(rng.normal(65_000, 15_000, 20_000)) > 0.25       # one-sigma shift

# Values beyond the training range must count, not vanish.
same = rng.normal(50_000, 15_000, 10_000)
beyond = np.full(10_000, train.max() * 10)          # upstream switched rupees -> paise
assert mon.psi(np.concatenate([same, beyond])) > 1.0
assert mon.psi(np.concatenate([same, -beyond])) > 1.0

# A null spike is drift even if the non-null values look normal.
with_nulls = rng.normal(50_000, 15_000, 10_000)
with_nulls[:3_000] = np.nan
assert mon.psi(with_nulls) > 0.25

# Heavily tied reference (70% zeros, e.g. 'loans in last 90 days'): cuts collapse, still works.
tied = np.concatenate([np.zeros(7_000), rng.integers(1, 6, 3_000).astype(float)])
tmon = PsiMonitor(tied, n_bins=10)
assert tmon.n_bins_effective < 10
assert tmon.psi(np.concatenate([np.zeros(700), rng.integers(1, 6, 300).astype(float)])) < 0.05
assert tmon.psi(np.concatenate([np.zeros(300), rng.integers(1, 6, 700).astype(float)])) > 0.5

# Inputs are plain lists too; bad inputs raise.
assert PsiMonitor([1, 2, 3, 4], n_bins=2).psi([1, 2, 3, 4]) == 0.0
for make in (lambda: PsiMonitor([np.nan, np.nan]), lambda: PsiMonitor([1.0], n_bins=1),
             lambda: mon.psi([])):
    try:
        make()
    except ValueError:
        pass
    else:
        raise AssertionError("bad input accepted")

A common wrong solution (the tests catch it):

import numpy as np


class PsiMonitor:
    def __init__(self, reference, n_bins: int = 10, eps: float = 1e-4) -> None:
        ref = np.asarray(reference, dtype=float)
        ref = ref[~np.isnan(ref)]
        if ref.size == 0:
            raise ValueError("reference has no non-missing values")
        if n_bins < 2:
            raise ValueError("n_bins must be >= 2")
        self.edges = np.unique(np.quantile(ref, np.linspace(0, 1, n_bins + 1)))
        self.eps = eps
        self.expected = self._proportions(ref)

    @property
    def n_bins_effective(self) -> int:
        return len(self.edges) - 1

    def _proportions(self, values: np.ndarray) -> np.ndarray:
        values = values[~np.isnan(values)]
        if values.size == 0:
            raise ValueError("no values to compare")
        counts, _ = np.histogram(values, bins=self.edges)
        return np.maximum(counts / counts.sum(), self.eps)

    def psi(self, live) -> float:
        actual = self._proportions(np.asarray(live, dtype=float))
        return float(np.sum((actual - self.expected) * np.log(actual / self.expected)))

Alternatives and tradeoffs:

  • KS statistic / Wasserstein distance: better for continuous features and don't depend on binning, but don't decompose per bin and have no "missing" notion; PSI's per-bin contributions explain where it moved.
  • Jensen–Shannon divergence: symmetric and bounded, so thresholds are easier to set across features.
  • Fixed-width bins are easier to explain but waste bins on skewed features like income; quantile bins put equal reference mass in each.
  • Monitoring the model output and simple stats (null rate, min/max, mean, share of values outside the training range) catches unit changes immediately and cheaply; PSI is one signal among several.

Follow-ups:

  1. "Your team sets a global alert at PSI > 0.2 on all 300 features and gets paged every night. What do you change?" PSI depends on sample size and binning; with small hourly samples, noise alone gives values near the threshold. Compare windows of enough data (or compute a bootstrap baseline of PSI between reference halves), tier features by importance, alert on sustained drift over N windows, and add cheap hard checks (null rate, out-of-range share, unit sanity) that are near-zero noise. Route to the feature owner with the per-bin breakdown.
  2. "Why ship the reference cut points with the model rather than recomputing them from the training table when monitoring?" The training table changes (backfills, late data, retention), so recomputed bins would drift with it and stop describing what the model was trained on. Fitting once and versioning the profile with the model artifact keeps the comparison reproducible, and lets you compare model versions trained on different data.
  3. "PSI fired on monthly_income, but the model's score distribution is unchanged. Do you retrain?" Not automatically. First find the cause: a pipeline bug (units, joins, defaults) is fixed upstream, not by retraining on corrupted data. If it's a real population shift, check the feature's importance and the model's performance on labelled recent data (or proxies) before retraining. Drift is a trigger for investigation, not a retraining button.

Scoring: Strong: Open-ended bins via searchsorted on interior cuts, a missing bin, eps clipping, ties handled with unique; explains why the min/max histogram went blind and links the incident to unit changes. Discusses alert noise and versioned reference profiles. Acceptable: Correct PSI with quantile bins, but needs the hint to handle out-of-range values or NaN; fixes them once pointed out. Red flag: Uses np.histogram over the reference range without noticing dropped values, drops NaN before computing, or computes bins from the live data (so every window looks like the reference).

SR-MLE-D01 · Real-time fraud scoring for UPI payments in under 50 ms

Hard · Design · Senior (5–8 years) · model serving, latency budgets, feature store, online/offline consistency, process model, canary and shadow deploys, fallbacks, ML monitoring · 45 min

Scenario:

A payments app in India must score every UPI payment for fraud before it is sent to the bank switch. The payments service calls your scoring API synchronously and will wait at most 60 ms; your budget is p99 ≤ 50 ms end to end. Traffic averages 800 requests/s and peaks at 3,000 requests/s on salary days and during festival sales.

The model is a gradient-boosted tree ensemble (~400 MB in memory) trained daily in Python on 90 days of history. It uses ~120 features: request fields (amount, payee VPA, device), aggregates ("payments by this sender in the last 10 minutes", "distinct payees in 24 h", "is the payee newly registered") and a few slower signals (device reputation from a vendor). Today the model runs as a nightly batch that flags accounts after the fact; fraud ops wants real-time blocking, with a rule-based fallback.

Design the online scoring system in Python: serving process, features, deployment of new models, and what happens when things fail.

Clarifying questions:

  • What's the decision contract: a score only, or allow / step-up (extra auth) / block? Who owns thresholds, ML or fraud ops? (Score plus a decision from thresholds owned by fraud ops, configurable without a deploy.)
  • What must happen if scoring is slow or down: fail open (allow the payment) or fail closed? (Fail open with rules, for amounts under ₹10,000; above that, step-up authentication.)
  • How fresh must the aggregate features be? Is a 10-minute window that lags by 30 s acceptable? (Yes for most; 'payments in the last 60 s' must be near-real-time.)
  • Where do the training features come from, and can we log the exact features used online? (Training reads a warehouse; there is no feature logging today.)
  • Do we get labels, and how late? (Chargebacks and fraud reports arrive 2–45 days later.)
  • Regulatory constraints: do we need to explain a block decision to the customer or the regulator? Data residency? (Reason codes required; data stays in India.)

Reference design:

Latency budget first. 50 ms p99 split roughly: 5 ms network and parsing, 15 ms feature fetch (one round-trip, in parallel), 10 ms model inference, 5 ms decision and logging, ~15 ms headroom. Everything that can't fit (vendor device reputation at 80 ms p99) is fetched asynchronously and cached per device, or precomputed, never awaited on the critical path.

1. Serving process. FastAPI on Uvicorn, several worker processes per pod (the GBM inference releases the GIL in the native library, but request parsing and feature assembly are Python and CPU-bound, so processes, not threads, give parallelism). Load the model before fork (Gunicorn preload_app) so workers share its pages copy-on-write, and freeze it with gc.freeze() after loading to stop the GC from touching (and copying) those pages. Endpoints are async and do I/O with httpx/Redis async clients; inference is fast enough (~1–3 ms for one row) to run inline, or it goes through asyncio.to_thread if it exceeds ~5 ms. Pods sized for peak at ≤ 60% CPU so p99 doesn't collapse under queueing; load-shed with 503 above a concurrency limit (the caller then applies the fallback).

2. Features: one definition, two paths. A feature store pattern: aggregates are computed by a streaming job (Kafka → Flink/Bytewax or a Python consumer) that maintains per-sender and per-payee windows in Redis (sorted sets or bucketed counters with TTL), written with the same feature definitions used to build training data. Online fetch is a single pipelined Redis call for all keys. Request-time features (amount, hour in IST, amount vs sender's 30-day median) are computed in a shared Python module imported by both training and serving, so the transform code is literally the same.

3. Training-serving consistency. Log the exact feature vector and model version for every scored request (to Kafka → warehouse). Train the next model on these logged features joined with later labels, where possible; and run a daily job comparing logged online features to recomputed offline ones (mismatch rate per feature, alert above 0.5%). This catches skew that would otherwise surface as an unexplained AUC drop.

4. Model lifecycle. The daily training job registers a model with its feature list, schema version, training data window, and offline metrics. Promotion: automatic checks (AUC/recall at fixed false-positive rate vs the current model on the same holdout, calibration, latency benchmark on the serving image), then shadow (scores logged, not used) for 24 h, then a canary (5% of traffic decides with the new model) with automatic rollback on error rate, latency, or block-rate shift beyond bounds. Models are loaded by version at startup; rollback is a config change, and two versions can be resident for shadowing.

5. Failure modes. Timeouts on every dependency (Redis 10 ms, total internal deadline 45 ms). If Redis is slow or down, score with request-time features only using a fallback model trained on that subset, plus rules; mark the decision degraded for monitoring. Circuit breaker on the vendor signal. If the service is down entirely, the payments service applies the agreed fail-open/step-up policy, so this service can never block all payments.

6. Monitoring. Latency per stage (histograms, not averages), error and fallback rates, score distribution and block rate per hour vs the same hour last week (a sudden shift is usually a bug, not fraud), feature null rates and PSI against the training reference, and delayed-label metrics (precision/recall once chargebacks arrive) on a weekly dashboard. Reason codes per decision (top contributing features from the tree model) for the support team and regulator.

Trade-offs:

  • Python + FastAPI vs a dedicated inference server (Triton, a compiled model in Go/Rust). Python keeps feature code shared with training and is fast enough at 3,000 RPS with ~10 pods; a compiled model server cuts per-request CPU, but the feature assembly is the hard part, and duplicating it in another language risks skew. Revisit if CPU cost dominates.
  • Precomputed aggregates vs on-request computation. Streaming aggregates in Redis make reads O(1) and fast, at the cost of a streaming job and eventual consistency (seconds). Computing "payments in last 10 minutes" from the database per request is simpler but adds a query to the hot path and load to the payments DB.
  • Fail open vs fail closed. Fail closed stops fraud during outages but blocks legitimate payments, which is a visible incident with regulatory implications; fail open with rules and step-up for high amounts is the usual balance. The point is to make it an explicit, product-owned policy.
  • Daily retraining vs stability. Fresh models track new fraud patterns, but each release is a risk. Automated gates plus shadow/canary make daily retraining safe; without them, weekly is wiser.
  • Batching. Dynamic batching improves throughput for deep models; for a single-row GBM call, batching adds latency for little gain. Don't batch here.

Deep-dive probes:

  • "After a deploy, p99 jumps from 35 ms to 120 ms but p50 is unchanged." Look for: per-stage latency histograms, GC pauses (fixed by gc.freeze() after loading, tuning thresholds), a cold model or cache in new workers (warm-up requests before readiness), CPU throttling from container limits, and Redis tail latency; profile with py-spy on a live worker.
  • "Each pod's memory is 6 GB with 8 workers and the model is 400 MB. Why, and how do you fix it?" Look for: the model loaded per worker after fork (8 copies), or copy-on-write defeated by reference counting and GC touching object headers; preload before fork plus gc.freeze(), fewer workers per pod with more pods, or a model format that keeps parameters in a few large arrays.
  • "Fraud ops says block rate doubled overnight but fraud reports didn't change." Look for: comparing feature distributions and null rates first (an upstream field change), checking which model/threshold version is live, the logged feature vectors for a sample of blocked payments, and a rollback path while investigating.
  • "A feature 'payee received from 50+ distinct senders in 1 h' takes 30 ms to compute from raw events." Look for: maintaining it incrementally in the streaming job (HyperLogLog in Redis for distinct counts), accepting approximation, and the same definition in training.

Rubric: Strong: Starts from a stage-by-stage latency budget; processes with preload + gc.freeze() and async I/O; one feature-definition codebase with streaming aggregates in Redis and logged online features; shadow → canary → rollback with automated gates; explicit fail-open/step-up policy and a degraded fallback model; monitoring of latency stages, score/block-rate shifts and delayed-label quality. Acceptable: A sound FastAPI service with Redis features, a model registry and canaries, but vague on the latency budget, training-serving skew, or what happens when a dependency is slow. Red flag: Queries the payments database for aggregates on every request, loads the model per request, ignores the timeout/fallback policy, or measures success by offline AUC alone.

SR-MLE-O01 · Unpickling an old model artifact against new serving code

Medium & Tricky · Output · Senior (5–8 years) · pickle, model artifacts, versioning, __init__ vs __setstate__, model serving, backward compatibility · 7 min

Question: The training pipeline pickles a ChurnModel and uploads it to the model registry. A week later, the serving team adds a decision threshold to the class and deploys, but the registry still serves last week's artifact. In production the two halves run in different processes; here they are squeezed into one file, with the class redefined under the same name (which is what pickle sees: it stores only module.qualname and the instance __dict__). What does this print?

import pickle


# --- training repo, release 1.4 ---
class ChurnModel:
    def __init__(self, weights):
        self.weights = weights

    def predict(self, x):
        return sum(w * v for w, v in zip(self.weights, x))


artifact = pickle.dumps(ChurnModel([0.5, -0.25]))


# --- serving repo, release 1.5: threshold added, artifact NOT retrained ---
class ChurnModel:
    VERSION = "1.5"

    def __init__(self, weights, threshold=0.4):
        self.weights = weights
        self.threshold = threshold
        print("init called")

    def predict(self, x):
        score = sum(w * v for w, v in zip(self.weights, x))
        return score >= self.threshold


model = pickle.loads(artifact)
print(type(model).__name__, model.VERSION, vars(model))
print(hasattr(model, "threshold"))
print(model.predict([1.0, 0.4]))

Expected output:

ChurnModel 1.5 {'weights': [0.5, -0.25]}
False
AttributeError: 'ChurnModel' object has no attribute 'threshold'

The trap: "init called, then ChurnModel 1.5 {'weights': [0.5, -0.25], 'threshold': 0.4}, True, True." The candidate assumes unpickling constructs the object through __init__, so the new default fills the gap. The fact that VERSION shows 1.5 makes the object look fully upgraded.

Explanation:

Pickling a plain instance stores a reference to its class (__main__.ChurnModel) plus the instance __dict__. Unpickling looks the class up by name at load time, gets whatever class currently has that name (the 1.5 one), creates an empty instance with cls.__new__(cls), and sets __dict__ directly. __init__ is never called, so init called never prints and threshold is never set.

So the object is a hybrid: new-class behaviour (VERSION is a class attribute, predict is the new method) on top of old-class state ({'weights': [...]}). The first request that reaches predict crashes with AttributeError. In real systems the failure mode is often worse than a crash: if the 1.5 class had declared threshold = 0.5 as a class attribute "for safety", the old artifact would silently score with a threshold nobody trained or validated.

This is the core reason pickle is a poor model interchange format across releases: - the artifact is coupled to the exact import path and attribute layout of the code that wrote it (moving ChurnModel to another module breaks loading with AttributeError: Can't get attribute); - loading runs arbitrary code from the artifact (__reduce__), so an artifact from an untrusted bucket is a remote-code-execution risk.

What a senior MLE puts in place: store the schema version in the artifact and the registry, check it at load time against what the serving code supports (fail the deploy, not the request), implement __setstate__ for deliberate migrations, run a load-and-predict smoke test on the exact registry artifact in CI before rollout, and prefer data-only formats (weights as arrays/JSON, ONNX, the framework's native format) over pickling whole Python objects.

Takeaway: unpickling restores __dict__ into whatever class has that name today without calling __init__; version your artifacts and test old artifacts against new code.

Follow-ups:

  1. "Write the __setstate__ that lets release 1.5 load 1.4 artifacts safely." def __setstate__(self, state): state.setdefault('threshold', 0.4); state.setdefault('schema', 1); self.__dict__.update(state). Better still, store schema explicitly in __getstate__, and in __setstate__ migrate per version and raise on unknown future versions, so a rollback can't load a newer artifact by accident.
  2. "How would you catch this before production rather than on the first request?" A deploy gate: load the exact artifact the registry will serve with the new serving image, run predictions on a golden input set and compare with the scores recorded at training time. Also a startup check that loads the model and calls predict once, failing the readiness probe so the rollout halts.
  3. "Your security team says no more pickles from the shared bucket. What do you replace it with?" Store parameters as data (NumPy .npz loaded with allow_pickle=False, JSON, safetensors-style formats, ONNX for full graphs) plus a manifest (schema version, feature list, training data hash, metrics), and sign or checksum artifacts in the registry. If pickle stays for some library, restrict loading to signed artifacts from a write-protected registry.

Scoring: Strong: Exact output: no init called, VERSION 1.5 with old __dict__, False, then the AttributeError. Explains that pickle restores state without __init__ and looks classes up by name, and proposes versioned artifacts, __setstate__ migration and a load-and-predict deploy gate; mentions the security risk unprompted. Acceptable: Realises __init__ is skipped after a hint about how objects are rebuilt, then predicts the AttributeError and suggests __setstate__ or retraining. Red flag: Believes pickle stores the class code inside the artifact, or proposes catching AttributeError in predict and returning a default.

SR-BE-C01 · Admission control that sheds load instead of queueing forever

Hard · Coding · Senior (5–8 years) · asyncio, load shedding, backpressure, concurrency limits, cancellation safety, async context managers · 35 min

Problem:

During a cricket-final sale, the order service's downstream (inventory DB) can handle about 200 concurrent queries per pod. When traffic triples, the pods queue requests in memory, latency goes to 30 s, clients time out and retry, and the queue grows further: a retry storm. You decide to shed load early: run at most limit requests at once, let at most max_waiting wait, and reject the rest immediately with 503 so clients back off.

Implement AdmissionLimiter(limit, max_waiting) used as async with limiter.slot(): ...:

  • If fewer than limit are running and nobody is waiting, enter immediately.
  • Otherwise wait in FIFO order; if max_waiting requests are already waiting, raise Overloaded immediately without waiting.
  • Leaving the block (normally or with an exception) frees the slot and hands it to the next waiter; a newcomer must not jump the queue.
  • Cancellation safety: a waiter cancelled while queued (client disconnected, request timeout) gives up its place. A waiter that is cancelled after the slot was handed to it but before it resumed must pass the slot on, so slots never leak.
  • Expose active and waiting counts for metrics. Invalid arguments raise ValueError.

Signature: class AdmissionLimiter: def __init__(self, limit: int, max_waiting: int) -> None: ... @asynccontextmanager async def slot(self) -> AsyncIterator[None]: ...

Constraints: Python 3.12 standard library only. Single event loop, so no thread locks needed; correctness must not depend on timing (no sleeps). Expected scale: thousands of requests per second per pod.

Examples:

  • limit=2, max_waiting=3; six requests arrive together and hold their slot → 2 running, 3 waiting, the 6th raises Overloaded at once; when released, they run in arrival order
  • a waiter is cancelled while queued → waiting drops by one; the next release goes to the following waiter
  • the slot is handed to waiter W, then W is cancelled before it runs → the slot passes to the next waiter; active returns to 0 when all finish

Hints:

  1. Represent each waiter as a future in a deque; releasing a slot means resolving the first future that isn't already done, without decrementing active.
  2. When await fut raises CancelledError, look at the future: was it cancelled, or had it already been given a result?
  3. Use try/finally to remove the future from the deque on every exit path.

Reference solution:

import asyncio
from collections import deque
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager


class Overloaded(Exception):
    """Raised immediately when the wait queue is full: map it to HTTP 503 + Retry-After."""


class AdmissionLimiter:
    """At most `limit` requests run concurrently; at most `max_waiting` wait (FIFO); the rest are shed."""

    def __init__(self, limit: int, max_waiting: int) -> None:
        if limit < 1 or max_waiting < 0:
            raise ValueError("limit >= 1 and max_waiting >= 0 required")
        self._limit = limit
        self._max_waiting = max_waiting
        self._active = 0
        self._waiters: deque[asyncio.Future[None]] = deque()

    @property
    def active(self) -> int:
        return self._active

    @property
    def waiting(self) -> int:
        return sum(1 for f in self._waiters if not f.done())

    @asynccontextmanager
    async def slot(self) -> AsyncIterator[None]:
        await self._acquire()
        try:
            yield
        finally:
            self._release()

    async def _acquire(self) -> None:
        if self._active < self._limit and not self.waiting:
            self._active += 1
            return
        if self.waiting >= self._max_waiting:
            raise Overloaded(f"{self._active} running, {self.waiting} waiting")
        fut: asyncio.Future[None] = asyncio.get_running_loop().create_future()
        self._waiters.append(fut)
        try:
            await fut
        except asyncio.CancelledError:
            if fut.done() and not fut.cancelled():
                # The slot was handed to us just before we were cancelled: pass it on.
                self._release()
            raise
        finally:
            try:
                self._waiters.remove(fut)
            except ValueError:
                pass

    def _release(self) -> None:
        # Hand the slot directly to the next live waiter, so a newcomer can't barge in.
        while self._waiters:
            fut = self._waiters.popleft()
            if not fut.done():
                fut.set_result(None)
                return
        self._active -= 1

Complexity: Enter and leave are O(1) amortised (deque append/popleft); removing a cancelled waiter is O(w) for w waiters, and waiting is O(w), both bounded by max_waiting, which is small by design. Memory O(max_waiting).

Tests:

import asyncio


async def main() -> None:
    lim = AdmissionLimiter(limit=2, max_waiting=3)
    peak = 0
    order: list[int] = []

    async def handler(i: int, hold: asyncio.Event) -> int:
        nonlocal peak
        async with lim.slot():
            peak = max(peak, lim.active)
            order.append(i)
            await hold.wait()
            return i

    # 2 run, 3 wait, the 6th is shed immediately.
    gate = asyncio.Event()
    tasks = [asyncio.create_task(handler(i, gate)) for i in range(6)]
    await asyncio.sleep(0)
    assert (lim.active, lim.waiting) == (2, 3), (lim.active, lim.waiting)
    async with asyncio.timeout(1):
        try:
            await tasks[5]
        except Overloaded:
            pass
        else:
            raise AssertionError("6th request was not shed")
    gate.set()
    async with asyncio.timeout(1):
        assert await asyncio.gather(*tasks[:5]) == [0, 1, 2, 3, 4]
    assert peak == 2 and order == [0, 1, 2, 3, 4]          # FIFO admission
    assert (lim.active, lim.waiting) == (0, 0)

    # An exception inside the slot releases it.
    try:
        async with lim.slot():
            raise KeyError("boom")
    except KeyError:
        pass
    assert lim.active == 0

    # A waiter cancelled while queued (client went away) gives up its place.
    one = AdmissionLimiter(limit=1, max_waiting=2)
    hold = asyncio.Event()
    holder = asyncio.create_task(handler_on(one, hold))
    quitter = asyncio.create_task(handler_on(one, asyncio.Event()))
    await asyncio.sleep(0)
    quitter.cancel()
    await asyncio.sleep(0)
    assert one.waiting == 0
    hold.set()
    async with asyncio.timeout(1):
        await holder
    assert one.active == 0

    # The nasty case: the slot is handed to a waiter, which is cancelled before it runs.
    # The slot must pass on to the next waiter instead of leaking.
    two = AdmissionLimiter(limit=1, max_waiting=2)
    release = asyncio.Event()
    victim_box: list[asyncio.Task] = []

    async def first() -> None:
        async with two.slot():
            await release.wait()
        victim_box[0].cancel()            # slot already handed to the victim; now cancel it

    t1 = asyncio.create_task(first())
    await asyncio.sleep(0)
    victim = asyncio.create_task(handler_on(two, asyncio.Event()))
    third_done = asyncio.Event()
    third = asyncio.create_task(handler_on(two, third_done, set_first=True))
    victim_box.append(victim)
    await asyncio.sleep(0)
    assert two.waiting == 2
    release.set()
    async with asyncio.timeout(1):
        await t1
        await third
    assert victim.cancelled()
    assert two.active == 0 and two.waiting == 0


async def handler_on(lim: AdmissionLimiter, ev: asyncio.Event, set_first: bool = False) -> None:
    async with lim.slot():
        if set_first:
            ev.set()
        await ev.wait()


asyncio.run(main())

for args in [(0, 1), (1, -1)]:
    try:
        AdmissionLimiter(*args)
    except ValueError:
        pass
    else:
        raise AssertionError(args)

A common wrong solution (the tests catch it):

import asyncio
from collections import deque
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager


class Overloaded(Exception):
    """Raised immediately when the wait queue is full: map it to HTTP 503 + Retry-After."""


class AdmissionLimiter:
    """At most `limit` requests run concurrently; at most `max_waiting` wait (FIFO); the rest are shed."""

    def __init__(self, limit: int, max_waiting: int) -> None:
        if limit < 1 or max_waiting < 0:
            raise ValueError("limit >= 1 and max_waiting >= 0 required")
        self._limit = limit
        self._max_waiting = max_waiting
        self._active = 0
        self._waiters: deque[asyncio.Future[None]] = deque()

    @property
    def active(self) -> int:
        return self._active

    @property
    def waiting(self) -> int:
        return sum(1 for f in self._waiters if not f.done())

    @asynccontextmanager
    async def slot(self) -> AsyncIterator[None]:
        await self._acquire()
        try:
            yield
        finally:
            self._release()

    async def _acquire(self) -> None:
        if self._active < self._limit and not self.waiting:
            self._active += 1
            return
        if self.waiting >= self._max_waiting:
            raise Overloaded(f"{self._active} running, {self.waiting} waiting")
        fut: asyncio.Future[None] = asyncio.get_running_loop().create_future()
        self._waiters.append(fut)
        try:
            await fut
        finally:
            try:
                self._waiters.remove(fut)
            except ValueError:
                pass

    def _release(self) -> None:
        # Hand the slot directly to the next live waiter, so a newcomer can't barge in.
        while self._waiters:
            fut = self._waiters.popleft()
            if not fut.done():
                fut.set_result(None)
                return
        self._active -= 1

Alternatives and tradeoffs:

  • asyncio.Semaphore plus a counter: in 3.12 the semaphore already handles the handed-then-cancelled race internally; a waiting counter around acquire() gives the shedding behaviour with less code. Writing it by hand is the interview exercise because it shows you know why that race exists.
  • Deadline-aware shedding: carry the client's deadline and drop requests whose remaining budget is smaller than the typical service time, even if a slot is free. Better goodput under overload than a fixed queue length.
  • Adaptive limits (AIMD / gradient algorithms like Netflix's concurrency-limits): lower limit when latency rises, raise it when healthy, instead of a hand-tuned constant.
  • Shed at the edge: a rate limit at the load balancer or API gateway is cheaper, but it doesn't know a pod's actual concurrency or downstream health; the in-process limiter does. Use both.

Follow-ups:

  1. "How do you pick limit and max_waiting for this pod?" limit from the downstream's capacity divided by pod count (here the DB pool size per pod is the natural limit), validated by load tests at the knee of the latency curve. max_waiting from the latency budget: if the p50 service time is 50 ms and the SLO is 500 ms, a waiter behind ~limit × 9 others will miss it anyway, so cap the queue around that and shed the rest.
  2. "You return 503 with Retry-After. What must clients do so shedding actually helps?" Retry with exponential backoff and jitter, honour Retry-After, cap total retries, and use a retry budget (e.g. retries ≤ 10% of requests) so a fleet of clients doesn't turn shedding into amplified load. Only retry idempotent requests, or send idempotency keys.
  3. "Where would you put this limiter in a FastAPI app, and what would you measure?" As middleware or a dependency around the routes that hit the constrained downstream (not health checks), created in the lifespan so it's bound to the running loop. Export active, waiting, shed count, and wait-time histograms; alert on sustained shedding and use waiting as an autoscaling signal.

Scoring: Strong: FIFO handoff without barging, immediate rejection when the queue is full, release in finally, and the cancelled-after-handoff case handled with a clear explanation of the race. Discusses retry storms, client backoff and how to size the limits. Acceptable: Works for the normal paths and for cancellation while queued; misses the handoff race until the scenario is described, then fixes it. Red flag: Uses an unbounded queue, uses asyncio.sleep polling, or leaks slots on exceptions.

SR-BE-C02 · Hedged reads to cut tail latency, without leaking requests

Medium · Coding · Senior (5–8 years) · tail latency, hedged requests, asyncio.wait, cancellation, ExceptionGroup, idempotency · 25 min

Problem:

The product page of a shopping app calls a pricing service with 3 read replicas. Median latency is 8 ms, but p99 is 400 ms because any replica occasionally stalls (GC pauses, a noisy neighbour on the host). Since the page waits for price, the page's p99 follows the slowest replica. Reads are idempotent, so you'll implement hedged requests: if the first replica hasn't answered within roughly its p95, send the same request to the next replica and take whichever answers first.

Implement async hedged_call(fetch, replicas, hedge_after):

  • Start fetch(replicas[0]). Each time hedge_after seconds pass with no result, start the next replica (in order), while keeping the earlier attempts running.
  • If an attempt fails, start the next replica immediately rather than waiting for the timer.
  • Return the first successful result, and cancel every other in-flight attempt before returning (wait for them to finish cancelling, so nothing outlives the call).
  • Never call a replica twice; at most len(replicas) attempts.
  • If every attempt fails, raise ExceptionGroup("all replicas failed", [...]) with all the errors.
  • If the caller is cancelled (its own timeout), cancel all attempts too.
  • Empty replicas or negative hedge_after → ValueError.

Signature: async def hedged_call(fetch: Callable[[str], Awaitable[T]], replicas: Sequence[str], hedge_after: float) -> T:

Constraints: Python 3.12 asyncio only. fetch is an async function (e.g. an httpx.AsyncClient GET). The function is on the hot path of every page view, so no polling loops or sleeps.

Examples:

  • replica a stalls for 1 s, b answers in 10 ms; hedge_after=0.05 → 'price from b' after ~60 ms; the attempt on a is cancelled
  • replica b answers in 10 ms, listed first → 'price from b'; no second request is sent
  • a fails at once, b answers in 10 ms; hedge_after=5 → 'price from b' in ~10 ms, not 5 s

Hints:

  1. asyncio.wait(tasks, timeout=..., return_when=FIRST_COMPLETED) tells you both 'something finished' and 'the timer expired' in one call.
  2. Keep a set of in-flight tasks and an iterator over the replicas still unused.
  3. Where do you put the cancellation so it runs on success, on total failure, and when the caller itself is cancelled?

Reference solution:

import asyncio
from collections.abc import Awaitable, Callable, Sequence
from typing import TypeVar

T = TypeVar("T")


async def hedged_call(fetch: Callable[[str], Awaitable[T]], replicas: Sequence[str],
                      hedge_after: float) -> T:
    """Call `fetch` on replicas[0]; if it hasn't answered within `hedge_after` seconds (or it
    failed), also try the next replica. Return the first success and cancel the rest.

    Only for idempotent reads: several replicas may execute the same request.
    """
    if not replicas:
        raise ValueError("need at least one replica")
    if hedge_after < 0:
        raise ValueError("hedge_after must be >= 0")

    remaining = iter(replicas)
    pending: set[asyncio.Task[T]] = set()
    errors: list[Exception] = []

    def launch_next() -> bool:
        replica = next(remaining, None)
        if replica is None:
            return False
        pending.add(asyncio.create_task(fetch(replica), name=f"hedge:{replica}"))
        return True

    launch_next()
    try:
        while pending:
            done, _ = await asyncio.wait(pending, timeout=hedge_after,
                                         return_when=asyncio.FIRST_COMPLETED)
            if not done:                       # slow: hedge to the next replica
                launch_next()
                continue
            pending -= done
            for task in done:
                if task.exception() is None:
                    return task.result()
            errors.extend(task.exception() for task in done)
            launch_next()                      # failed: don't wait for the timer
        raise ExceptionGroup("all replicas failed", errors)
    finally:
        for task in pending:                   # losers, or everything if we were cancelled
            task.cancel()
        if pending:
            await asyncio.gather(*pending, return_exceptions=True)

Complexity: At most len(replicas) attempts; each loop iteration is O(r) for r in-flight tasks in asyncio.wait. The extra load is the hedging rate: with hedge_after at the p95, about 5% of requests send a second copy, which is the whole point: ~5% more load for a much lower p99.

Tests:

import asyncio
import time

log: list[str] = []


def replica_behaviour(spec: dict[str, tuple[float, str | None]]):
    """spec: replica -> (delay seconds, error message or None)."""
    async def fetch(replica: str) -> str:
        log.append(f"start {replica}")
        delay, error = spec[replica]
        try:
            await asyncio.sleep(delay)
        except asyncio.CancelledError:
            log.append(f"cancelled {replica}")
            raise
        if error:
            raise ConnectionError(f"{replica}: {error}")
        return f"price from {replica}"
    return fetch


async def main() -> None:
    # The primary is stuck in a GC pause; the hedge answers, and the primary is cancelled.
    log.clear()
    fetch = replica_behaviour({"a": (1.0, None), "b": (0.01, None), "c": (0.01, None)})
    t0 = time.perf_counter()
    assert await hedged_call(fetch, ["a", "b", "c"], hedge_after=0.05) == "price from b"
    assert time.perf_counter() - t0 < 0.5
    assert log == ["start a", "start b", "cancelled a"], list(log)

    # A fast primary means no extra load at all.
    log.clear()
    assert await hedged_call(fetch, ["b", "a"], hedge_after=0.05) == "price from b"
    assert log == ["start b"], list(log)

    # A failure moves on immediately instead of waiting out the hedge delay.
    log.clear()
    fetch = replica_behaviour({"a": (0.0, "connection reset"), "b": (0.01, None)})
    t0 = time.perf_counter()
    assert await hedged_call(fetch, ["a", "b"], hedge_after=5.0) == "price from b"
    assert time.perf_counter() - t0 < 1.0

    # Everything fails: every error is reported.
    log.clear()
    fetch = replica_behaviour({"a": (0.0, "down"), "b": (0.01, "down"), "c": (0.0, "down")})
    try:
        await hedged_call(fetch, ["a", "b", "c"], hedge_after=0.05)
    except* ConnectionError as eg:
        assert sorted(str(e) for e in eg.exceptions) == ["a: down", "b: down", "c: down"]
    else:
        raise AssertionError("expected ExceptionGroup")

    # Never more attempts than replicas, even if all are slow.
    log.clear()
    fetch = replica_behaviour({"a": (0.3, None), "b": (0.3, None)})
    assert await hedged_call(fetch, ["a", "b"], hedge_after=0.01) == "price from a"
    assert [m for m in log if m.startswith("start")] == ["start a", "start b"]

    # The caller's own timeout cancels every in-flight attempt; no task outlives the call.
    log.clear()
    fetch = replica_behaviour({"a": (5.0, None), "b": (5.0, None)})
    try:
        async with asyncio.timeout(0.2):
            await hedged_call(fetch, ["a", "b"], hedge_after=0.05)
    except TimeoutError:
        pass
    else:
        raise AssertionError("expected timeout")
    assert sorted(log) == ["cancelled a", "cancelled b", "start a", "start b"], list(log)
    assert asyncio.all_tasks() == {asyncio.current_task()}

    for args in [([], 0.1), (["a"], -1)]:
        try:
            await hedged_call(fetch, *args)
        except ValueError:
            pass
        else:
            raise AssertionError(args)


asyncio.run(main())

A common wrong solution (the tests catch it):

import asyncio
from collections.abc import Awaitable, Callable, Sequence
from typing import TypeVar

T = TypeVar("T")


async def hedged_call(fetch: Callable[[str], Awaitable[T]], replicas: Sequence[str],
                      hedge_after: float) -> T:
    """Call `fetch` on replicas[0]; if it hasn't answered within `hedge_after` seconds (or it
    failed), also try the next replica. Return the first success and cancel the rest.

    Only for idempotent reads: several replicas may execute the same request.
    """
    if not replicas:
        raise ValueError("need at least one replica")
    if hedge_after < 0:
        raise ValueError("hedge_after must be >= 0")

    remaining = iter(replicas)
    pending: set[asyncio.Task[T]] = set()
    errors: list[Exception] = []

    def launch_next() -> bool:
        replica = next(remaining, None)
        if replica is None:
            return False
        pending.add(asyncio.create_task(fetch(replica), name=f"hedge:{replica}"))
        return True

    launch_next()
    while pending:
        done, pending = await asyncio.wait(pending, timeout=hedge_after,
                                           return_when=asyncio.FIRST_COMPLETED)
        if not done:                           # slow: hedge to the next replica
            launch_next()
            continue
        for task in done:
            if task.exception() is None:
                return task.result()           # the slower attempts finish on their own
        errors.extend(task.exception() for task in done)
        launch_next()
    raise ExceptionGroup("all replicas failed", errors)

Alternatives and tradeoffs:

  • Plain retry after a timeout (cancel the first, then try the next): lower load, but you pay the full timeout before retrying and lose the chance that the first attempt was about to answer.
  • Send to all replicas at once ("tied requests" without delay): lowest latency, but multiplies load by the replica count, so the service saturates under traffic. Hedging after the p95 is the usual compromise; some systems also cancel the duplicate on the server side once one copy starts executing.
  • asyncio.TaskGroup: great for "all must succeed" fan-out; for "first success wins" it's awkward because a failing attempt cancels the group. asyncio.wait with explicit cancellation fits this shape better.
  • Adaptive hedge_after: track a rolling p95 per endpoint and hedge only when the request is slower than that, with a budget (e.g. at most 5% hedged) so a slow replica set doesn't double traffic during an incident.

Follow-ups:

  1. "The pricing team says hedging doubled their traffic during last night's incident. What went wrong, and how do you guard against it?" When every replica slows down, every request crosses hedge_after, so every request is sent twice (or three times), adding load to an already struggling service. Use a hedging budget (token bucket allowing hedges for at most ~5% of requests), an adaptive threshold based on recent latency, and stop hedging when the error rate is high (circuit-breaker style).
  2. "Why must this only be used for idempotent requests? Give a concrete failure." Both attempts may execute: a hedged POST /orders could create two orders, or a hedged payment capture could charge twice. Writes need an idempotency key honoured by the server before they can be hedged, and even then server-side dedup must be atomic.
  3. "How do you verify in production that hedging actually helps?" Measure end-to-end p99 and p99.9 of the calling endpoint before and after, the hedge rate, how often the hedge wins, and backend QPS. Run it as an experiment on a fraction of traffic. If the hedge rarely wins, the tail is caused by something shared (the network, the DB), and hedging only adds load.

Scoring: Strong: Correct asyncio.wait loop with timer and failure both triggering the next attempt, cancellation of losers in finally with awaiting the cancelled tasks, ExceptionGroup for total failure, and caller cancellation handled. Explains the idempotency requirement and the load-amplification risk with a hedging budget. Acceptable: Returns the first success and hedges on time, but forgets to cancel losers, or waits for the timer after a failure; fixes it once asked what happens to the slow request. Red flag: Sends to all replicas every time without discussing load, uses time.sleep, or hedges non-idempotent writes.

SR-BE-D01 · Outbound webhook delivery for 30,000 merchants

Medium · Design · Senior (5–8 years) · webhooks, at-least-once delivery, retries and backoff, tenant isolation, asyncio, HMAC signing, SSRF, observability · 40 min

Scenario:

A payment gateway in India notifies merchants of events (payment.captured, refund.processed, settlement.completed) by POSTing JSON to a URL each merchant configures. About 30,000 merchants have webhooks; volume is ~6 million events/day, peaking at 2,000 events/s during festive sales.

Today a Celery task per event POSTs synchronously with requests and retries 3 times. Problems: when a few big merchants' servers go slow, all workers are stuck waiting on them and every merchant's webhooks are delayed by hours; merchants complain about duplicates and missing events; one merchant configured http://169.254.169.254/latest/meta-data/ as their URL and the security team is unhappy.

Design the webhook delivery system: how events are queued, delivered, retried, secured and observed, and what the merchant-facing contract is.

Clarifying questions:

  • What delivery guarantee do we promise merchants: at-least-once with an event id for dedup? Is per-merchant ordering promised? (At-least-once; no strict ordering, but events carry a sequence/timestamp.)
  • How long do we keep retrying a failing endpoint, and what happens after that? (72 hours, then the event goes to a dead-letter view the merchant can replay from the dashboard.)
  • What timeout is reasonable for merchant endpoints, and what counts as success? (2xx within 10 s; anything else is a failure.)
  • How are merchants told about failures, and can they replay events themselves? (Email after N hours failing; dashboard + API for replay.)
  • Is there an existing message broker (Kafka, SQS, RabbitMQ) and a Postgres we can use? (Kafka for events; Postgres for state.)
  • Any compliance constraints on payload contents (card data, PII)? (No card data; customer email and phone are present and must be minimised.)

Reference design:

Merchant contract. Each delivery is a POST with JSON body, headers Webhook-Id (the event id, stable across retries), Webhook-Timestamp, and Webhook-Signature: v1=<HMAC-SHA256(secret, id.timestamp.body)>. Merchants must verify the signature, reject timestamps older than 5 minutes, dedup on Webhook-Id, and respond 2xx fast (queue the work on their side). Documented, with SDK helpers for verification.

1. Intake. Payment services publish domain events to Kafka. A fan-out consumer looks up subscriptions (cached) and writes one delivery row per (event, endpoint) to Postgres: delivery_id, event_id, endpoint_id, status, attempt, next_attempt_at, last_error, with a unique key on (event_id, endpoint_id) so consumer replays don't create duplicates. Payloads are stored once per event (compressed), referenced by deliveries.

2. Dispatch with per-merchant isolation. Dispatcher processes claim due deliveries with SELECT ... WHERE next_attempt_at <= now() ... FOR UPDATE SKIP LOCKED LIMIT 500 (or partitioned queues per endpoint hash), and send them using asyncio + httpx.AsyncClient: thousands of in-flight requests per process, since this is pure network wait. Concurrency is limited per endpoint (e.g. 10 in flight) by a semaphore keyed by endpoint, and globally per process. A slow merchant fills only its own 10 slots; everyone else keeps flowing. Endpoints that fail repeatedly are put in a per-endpoint circuit-breaker / suspended state with probe deliveries, so they stop consuming capacity.

3. Retries. On timeout, connection error, 5xx or 429: reschedule with exponential backoff with full jitter (e.g. 30 s, 2 min, 10 min, 1 h … capped at 6 h), honouring Retry-After. On 4xx other than 408/429: retry a few times only (likely merchant bug), then dead-letter. After 72 hours: status failed, visible in the dashboard with the last response, and replayable. Attempts are recorded (status code, latency, truncated response) for support.

4. Security. Validate URLs at configuration and at send time: HTTPS only in production, resolve DNS and reject private, loopback, link-local and metadata ranges (re-check the resolved IP on each connection to defeat DNS rebinding, e.g. a custom transport that pins the validated IP), no redirects followed, response bodies read up to a small cap. Send from a dedicated egress with fixed IPs merchants can allowlist. Secrets are per endpoint, rotatable with two active secrets during rotation (two signatures in the header).

5. Observability. Per-endpoint success rate, latency, queue age (now - created_at of oldest pending delivery) and backlog; global delivery lag SLO (95% of first attempts within 30 s); alerts on dispatcher lag, not on individual merchant failures (those go to the merchant). Structured logs with event_id and delivery_id; a support tool shows every attempt for an event.

6. Capacity. 2,000 events/s × ~1.2 endpoints × average 300 ms response ≈ 720 concurrent requests at steady state, many more when merchants are slow (bounded by per-endpoint limits). A handful of async dispatcher processes handle this; Postgres handles the claim/update rate with partitioning of the deliveries table by day and indexes on (status, next_attempt_at).

Trade-offs:

  • Postgres as the delivery queue vs Kafka retry topics vs a managed queue (SQS). Postgres gives per-delivery state, ad-hoc queries for support, and easy scheduling with next_attempt_at; at ~6M deliveries/day plus retries it's well within limits with partitioning. Kafka retry topics scale further but make per-endpoint isolation and replay UI harder. A managed service (or a webhook platform vendor) is worth evaluating if this isn't core.
  • asyncio dispatcher vs Celery workers. Celery with sync HTTP needs one worker slot per in-flight request, so slow merchants exhaust the pool; asyncio holds thousands of waiting connections per process. The cost is discipline: no blocking calls in the dispatcher.
  • Ordering. Strict per-merchant ordering means one failing event blocks all later ones for that merchant (head-of-line blocking). Most gateways don't promise ordering and give timestamps/sequence numbers plus a "fetch current state" API; that's the recommendation.
  • Payload: full object vs thin event. Full payloads are convenient but leak PII into merchant logs and go stale on retries; thin events (id, type, link to fetch) force a call back to the API. A common compromise: full payload with minimal PII, plus the fetch API for the current state.

Deep-dive probes:

  • "A top-10 merchant's endpoint starts timing out at 10 s during Diwali." Look for: per-endpoint concurrency limits so it only consumes its own slots, circuit breaker/suspension with probes, backlog growth isolated to that merchant, alerts to their team, and catch-up rate limits when they recover so we don't flood them.
  • "A merchant says they received refund.processed twice and refunded the customer twice." Look for: at-least-once is the contract (timeouts after the merchant processed the request cause retries), the stable Webhook-Id for dedup, documentation and SDK support, and checking whether our side created two deliveries (unique key) vs one retried.
  • "Security finds a merchant URL that resolves to a public IP at save time and to 10.0.0.5 at send time." Look for: DNS rebinding, validating the resolved IP at connection time, pinning the IP for the request, blocking redirects, and egress firewall rules as defence in depth.
  • "Dispatcher lag grows to 20 minutes, but no merchant is slow." Look for: queue-age metric, the claim query's plan (index on status/next_attempt_at, table bloat from updates, autovacuum), dispatcher CPU (JSON serialisation, signing in Python), and scaling dispatchers horizontally since SKIP LOCKED lets them share the table.

Rubric: Strong: Clear merchant contract (event id, timestamps, HMAC signature, dedup expectation); durable delivery state with a unique key; asyncio dispatch with per-endpoint concurrency limits and circuit breaking; backoff with jitter and Retry-After; dead-letter + replay; SSRF defences including DNS rebinding; queue-age based SLOs. Explains why the Celery design failed. Acceptable: Queue + retries with backoff + signing, and some isolation, but misses SSRF details, the duplicate-delivery contract, or how a slow merchant is contained. Red flag: Promises exactly-once delivery, keeps one synchronous worker per in-flight request without isolation, retries immediately in a loop, or fetches any merchant URL without validation.

SR-BE-O01 · Read-modify-write stock decrement oversells under two workers

Medium & Tricky · Output · Senior (5–8 years) · transactions, lost update, race conditions, sqlite3, atomic conditional update, concurrency · 7 min

Question: A flash sale for the last IPL jersey in stock. Two API workers (separate processes in production, two connections here) handle checkouts that land milliseconds apart. The first block is the code that shipped; the second block is the fix from the incident review. What does this print?

import sqlite3
import tempfile
from pathlib import Path


def read_qty(conn):
    return conn.execute("SELECT qty FROM stock WHERE sku = 'IPL-JERSEY'").fetchone()[0]


def main(db):
    setup = sqlite3.connect(db)
    setup.execute("CREATE TABLE stock (sku TEXT PRIMARY KEY, qty INTEGER NOT NULL)")
    setup.execute("INSERT INTO stock VALUES ('IPL-JERSEY', 1)")
    setup.commit()
    worker_a, worker_b = sqlite3.connect(db), sqlite3.connect(db)

    # Shipped code: read, check in Python, write back.
    qty_a = read_qty(worker_a)
    qty_b = read_qty(worker_b)
    for name, conn, qty in [("A", worker_a, qty_a), ("B", worker_b, qty_b)]:
        if qty >= 1:
            conn.execute("UPDATE stock SET qty = ? WHERE sku = 'IPL-JERSEY'", (qty - 1,))
            conn.commit()
            print(f"order {name}: confirmed, qty was {qty}")
    print("qty now:", read_qty(setup))

    # Fix: let the database do the check and the decrement in one statement.
    setup.execute("UPDATE stock SET qty = 1")
    setup.commit()
    for name, conn in [("A", worker_a), ("B", worker_b)]:
        cur = conn.execute(
            "UPDATE stock SET qty = qty - 1 WHERE sku = 'IPL-JERSEY' AND qty >= 1")
        conn.commit()
        print(f"order {name}:", "confirmed" if cur.rowcount == 1 else "sold out")
    print("qty now:", read_qty(setup))
    for conn in (setup, worker_a, worker_b):
        conn.close()


with tempfile.TemporaryDirectory() as tmp:
    main(Path(tmp) / "shop.db")

Expected output:

order A: confirmed, qty was 1
order B: confirmed, qty was 1
qty now: 0
order A: confirmed
order B: sold out
qty now: 0

The trap: "order A confirmed, order B fails (or qty is -1)" for the first block. Candidates expect either the database to stop the second write ("it's in a transaction") or the arithmetic to show the oversell as a negative number. The final qty now: 0 looks perfectly healthy, which is exactly why the bug survives monitoring.

Explanation:

Shipped code: a lost update. Both workers read qty = 1 before either writes. Python's sqlite3 (legacy transaction control, the default) opens a transaction implicitly only before INSERT/UPDATE/DELETE, so each SELECT ran on its own and released its read immediately; there is nothing linking "what I read" to "what I write". Each worker then writes the absolute value it computed, qty = 0. Both commits succeed, both customers are charged, and the stock shows 0: the second write overwrote the first. One jersey, two confirmed orders, and no negative number anywhere to alert on.

Wrapping the read and write in one transaction is not automatically enough either: under READ COMMITTED (PostgreSQL's default) two transactions can still both read 1 and both write 0. You need the database to serialise the decision.

Fix: an atomic conditional update. UPDATE ... SET qty = qty - 1 WHERE ... AND qty >= 1 makes the check and the decrement a single statement that the database executes under a row (here, database) write lock. Worker A's update matches the row; worker B's re-evaluates qty >= 1 against the committed value 0, matches nothing, and rowcount == 0 tells the application to answer "sold out". The invariant lives where the data lives.

Other correct fixes and when to pick them: - SELECT ... FOR UPDATE (PostgreSQL/MySQL) when the decision needs more logic than a WHERE clause can express; it holds a row lock, so keep the transaction short. - Optimistic concurrency with a version column: UPDATE ... SET qty = ?, version = version + 1 WHERE sku = ? AND version = ?, retrying on rowcount == 0. Good for low-contention edits; under a flash sale it causes retry storms. - A CHECK (qty >= 0) constraint as a backstop: it converts a silent oversell into a loud error, but does not fix the absolute-write lost update.

Takeaway: read-check-write in application code is a race across workers; push the condition into a single atomic statement (or lock the row) and use rowcount to learn who won.

Follow-ups:

  1. "Would a threading.Lock around the checkout function have prevented this?" Only within one process. Production runs several Gunicorn/Uvicorn workers on several machines; a process-local lock does nothing across them. The serialisation point has to be shared: the database row, or a distributed lock with fencing, and the database is simpler and already authoritative.
  2. "The checkout also charges a payment and reserves a courier slot. How do you order those steps with the stock decrement?" Reserve stock first with the atomic decrement (cheap, reversible), then charge with an idempotency key, then confirm; if payment fails, release the reservation in a compensating update. Reservations get an expiry so abandoned checkouts return stock. Never charge first and then discover you're sold out.
  3. "How would you write a test that reliably reproduces the original race?" Don't rely on timing. Inject the interleaving: call the read for both workers, then both writes, as this snippet does, or use a hook/barrier between read and write in the code under test. A property test running N concurrent checkouts against a real database asserting confirmed <= initial_stock is a good second layer.

Scoring: Strong: Exact output, names it a lost update, explains why the absolute write hides the oversell, and gives the atomic conditional update with rowcount. Also knows a process lock doesn't help across workers and that READ COMMITTED transactions alone don't fix it. Acceptable: Sees the double confirmation and fixes it with SELECT FOR UPDATE or a version column, but needs a prompt to explain why qty ends at 0 rather than -1. Red flag: Thinks the database or 'the transaction' prevents it by default, or proposes a Python lock or a sleep/retry as the fix.

SR-FS-C01 · Stop two tabs overwriting each other with ETags and If-Match

Medium · Coding · Senior (5–8 years) · optimistic concurrency, ETag, If-Match, HTTP preconditions, FastAPI, lost updates, caching · 25 min

Problem:

Sellers on a marketplace edit product listings from a web dashboard. Support keeps getting tickets like "I changed the price, and an hour later it was back": a colleague had the same listing open in another tab and saved an older copy over it. Add optimistic concurrency to the API using standard HTTP preconditions.

Write create_app(store) returning a FastAPI app over an in-memory dict[int, Listing]:

  • GET /listings/{id} returns {"title", "price_paise"} with a strong ETag header derived from the listing's version, e.g. "7-v1" (quoted). If the request's If-None-Match matches (weak comparison: W/ prefixes ignored, * matches), return 304 with no body.
  • PATCH /listings/{id} with a partial JSON body (title and/or price_paise, unknown fields → 422):
  • no If-Match header → 428 Precondition Required;
  • If-Match not matching the current ETag → 412 Precondition Failed, including the current ETag header so the client can refetch and merge;
  • If-Match uses strong comparison: a W/ tag never matches; * matches any existing listing; a comma-separated list matches if any entry does;
  • on success: apply only the fields sent, bump the version, return 200 with the new ETag.
  • Unknown id → 404. A rejected request must not change anything.

Signature: def create_app(store: dict[int, Listing]) -> FastAPI:

Constraints: FastAPI + Pydantic v2. Endpoints may be plain def (run in FastAPI's thread pool), so think about what happens when two PATCHes run at the same moment. Use ListingPatch with extra="forbid" for the body.

Examples:

  • GET /listings/7 → 200, ETag: "7-v1"
  • PATCH price with If-Match: "7-v1" (Meera), then PATCH title with If-Match: "7-v1" (Arjun) → Meera 200 with ETag "7-v2"; Arjun 412 with ETag "7-v2"; Meera's price survives
  • PATCH without If-Match → 428

Hints:

  1. What does the client have to send so the server can tell 'I edited the version I saw' from 'I edited an old version'?
  2. Treat a missing If-Match as an error, not as 'no precondition': otherwise old clients silently keep the lost-update bug.
  3. Is checking the version and then writing atomic when two requests run in different threads? What's the database equivalent?

Reference solution:

import threading
from dataclasses import dataclass

from fastapi import FastAPI, Header, HTTPException, Response
from pydantic import BaseModel, ConfigDict


@dataclass
class Listing:
    title: str
    price_paise: int
    version: int = 1


class ListingPatch(BaseModel):
    model_config = ConfigDict(extra="forbid")
    title: str | None = None
    price_paise: int | None = None


def etag_of(listing_id: int, listing: Listing) -> str:
    return f'"{listing_id}-v{listing.version}"'          # strong validator, quoted per RFC 9110


def _matches(header: str, current: str) -> bool:
    """Strong comparison for If-Match: '*' or any listed strong tag equal to current."""
    if header.strip() == "*":
        return True
    tags = [t.strip() for t in header.split(",")]
    return any(t == current for t in tags if not t.startswith("W/"))


def _weak_matches(header: str, current: str) -> bool:
    """Weak comparison for If-None-Match: ignore any W/ prefix."""
    if header.strip() == "*":
        return True
    return any(t.strip().removeprefix("W/") == current for t in header.split(","))


def create_app(store: dict[int, Listing]) -> FastAPI:
    app = FastAPI()
    write_lock = threading.Lock()      # sync endpoints run in a thread pool: check-and-set must be atomic

    def get_or_404(listing_id: int) -> Listing:
        listing = store.get(listing_id)
        if listing is None:
            raise HTTPException(404, "listing not found")
        return listing

    @app.get("/listings/{listing_id}")
    def read(listing_id: int, response: Response,
             if_none_match: str | None = Header(default=None)):
        listing = get_or_404(listing_id)
        tag = etag_of(listing_id, listing)
        if if_none_match is not None and _weak_matches(if_none_match, tag):
            return Response(status_code=304, headers={"ETag": tag})
        response.headers["ETag"] = tag
        return {"title": listing.title, "price_paise": listing.price_paise}

    @app.patch("/listings/{listing_id}")
    def update(listing_id: int, patch: ListingPatch, response: Response,
               if_match: str | None = Header(default=None)):
        if if_match is None:
            raise HTTPException(428, "If-Match header required")
        with write_lock:
            listing = get_or_404(listing_id)
            current = etag_of(listing_id, listing)
            if not _matches(if_match, current):
                raise HTTPException(412, "listing was changed by someone else",
                                    headers={"ETag": current})
            for field, value in patch.model_dump(exclude_unset=True).items():
                setattr(listing, field, value)
            listing.version += 1
            response.headers["ETag"] = etag_of(listing_id, listing)
            return {"title": listing.title, "price_paise": listing.price_paise}

    return app

Complexity: O(1) per request plus parsing the If-Match list (a handful of tags). The lock serialises writes within one process only, held for microseconds.

Tests:

import warnings

warnings.simplefilter("ignore")  # TestClient deprecation noise
from fastapi.testclient import TestClient

store = {7: Listing("Handloom saree", 249_900)}
client = TestClient(create_app(store))

r = client.get("/listings/7")
assert r.status_code == 200 and r.headers["etag"] == '"7-v1"'
assert r.json() == {"title": "Handloom saree", "price_paise": 249_900}
assert client.get("/listings/7", headers={"If-None-Match": '"7-v1"'}).status_code == 304
assert client.get("/listings/7", headers={"If-None-Match": 'W/"7-v1"'}).status_code == 304
assert client.get("/listings/8").status_code == 404

# Two tabs (Meera on her laptop, her colleague Arjun on his phone) loaded version 1.
meera = client.patch("/listings/7", json={"price_paise": 199_900}, headers={"If-Match": '"7-v1"'})
assert meera.status_code == 200 and meera.headers["etag"] == '"7-v2"'
arjun = client.patch("/listings/7", json={"title": "Silk saree"}, headers={"If-Match": '"7-v1"'})
assert arjun.status_code == 412, arjun.status_code
assert arjun.headers["etag"] == '"7-v2"'                   # lets the client refetch and merge
assert store[7] == Listing("Handloom saree", 199_900, 2)    # Meera's change survived, untouched

# Missing precondition -> 428; weak tags never satisfy If-Match; lists and '*' work.
assert client.patch("/listings/7", json={"title": "x"}).status_code == 428
assert client.patch("/listings/7", json={"title": "x"}, headers={"If-Match": 'W/"7-v2"'}).status_code == 412
ok = client.patch("/listings/7", json={"title": "Silk saree"}, headers={"If-Match": '"7-v1", "7-v2"'})
assert ok.status_code == 200 and ok.json() == {"title": "Silk saree", "price_paise": 199_900}
assert client.patch("/listings/7", json={"price_paise": 1}, headers={"If-Match": "*"}).status_code == 200
assert store[7].version == 4

# Unknown fields are rejected, and a failed precondition changes nothing.
assert client.patch("/listings/7", json={"stock": 3}, headers={"If-Match": '"7-v4"'}).status_code == 422
assert client.patch("/listings/7", json={"price_paise": 5}, headers={"If-Match": '"7-v3"'}).status_code == 412
assert store[7] == Listing("Silk saree", 1, 4)
assert client.patch("/listings/9", json={"title": "x"}, headers={"If-Match": "*"}).status_code == 404

A common wrong solution (the tests catch it):

import threading
from dataclasses import dataclass

from fastapi import FastAPI, Header, HTTPException, Response
from pydantic import BaseModel, ConfigDict


@dataclass
class Listing:
    title: str
    price_paise: int
    version: int = 1


class ListingPatch(BaseModel):
    model_config = ConfigDict(extra="forbid")
    title: str | None = None
    price_paise: int | None = None


def etag_of(listing_id: int, listing: Listing) -> str:
    return f'"{listing_id}-v{listing.version}"'          # strong validator, quoted per RFC 9110


def _matches(header: str, current: str) -> bool:
    """Strong comparison for If-Match: '*' or any listed strong tag equal to current."""
    if header.strip() == "*":
        return True
    tags = [t.strip() for t in header.split(",")]
    return any(t == current for t in tags if not t.startswith("W/"))


def _weak_matches(header: str, current: str) -> bool:
    """Weak comparison for If-None-Match: ignore any W/ prefix."""
    if header.strip() == "*":
        return True
    return any(t.strip().removeprefix("W/") == current for t in header.split(","))


def create_app(store: dict[int, Listing]) -> FastAPI:
    app = FastAPI()
    write_lock = threading.Lock()      # sync endpoints run in a thread pool: check-and-set must be atomic

    def get_or_404(listing_id: int) -> Listing:
        listing = store.get(listing_id)
        if listing is None:
            raise HTTPException(404, "listing not found")
        return listing

    @app.get("/listings/{listing_id}")
    def read(listing_id: int, response: Response,
             if_none_match: str | None = Header(default=None)):
        listing = get_or_404(listing_id)
        tag = etag_of(listing_id, listing)
        if if_none_match is not None and _weak_matches(if_none_match, tag):
            return Response(status_code=304, headers={"ETag": tag})
        response.headers["ETag"] = tag
        return {"title": listing.title, "price_paise": listing.price_paise}

    @app.patch("/listings/{listing_id}")
    def update(listing_id: int, patch: ListingPatch, response: Response,
               if_match: str | None = Header(default=None)):
        with write_lock:
            listing = get_or_404(listing_id)
            current = etag_of(listing_id, listing)
            if if_match is not None and not _matches(if_match, current):
                raise HTTPException(412, "listing was changed by someone else",
                                    headers={"ETag": current})
            for field, value in patch.model_dump(exclude_unset=True).items():
                setattr(listing, field, value)
            listing.version += 1
            response.headers["ETag"] = etag_of(listing_id, listing)
            return {"title": listing.title, "price_paise": listing.price_paise}

    return app

Alternatives and tradeoffs:

  • In a real database the lock is replaced by a conditional write: UPDATE listings SET ..., version = version + 1 WHERE id = :id AND version = :v and checking rowcount (0 → 412). This is atomic across processes and pods; the in-process lock is not.
  • Content-hash ETags (hash of the serialised representation) need no version column and also work for caching, but are more expensive to compute and change if the serialisation changes.
  • Field-level merge / PATCH semantics: instead of rejecting, merge non-conflicting field changes server-side (Arjun changed title, Meera changed price). Friendlier, but you must define conflicts precisely; many teams start with 412 plus a client-side "reload and reapply" UX.
  • Pessimistic locking ("Meera is editing this listing"): good for long edits like documents; needs lock expiry and an override; overkill for short forms.

Follow-ups:

  1. "What should the frontend do when it gets a 412?" Refetch the listing, show the user what changed (diff of the fields they edited vs the new server values), and let them reapply or discard. Never automatically retry the same PATCH with the new ETag: that recreates the lost update with extra steps.
  2. "This API runs on 6 pods behind a load balancer. Is the solution still correct?" No. The threading.Lock and the in-memory dict are per process. The check-and-set must happen in the shared store: a conditional UPDATE ... WHERE version = ? in SQL, or a compare-and-set (WATCH/MULTI or a Lua script) in Redis. The HTTP contract (ETag, If-Match, 412/428) stays the same.
  3. "A CDN in front of the API is serving stale listings after updates. How do ETags and caching headers interact here?" ETags let caches revalidate cheaply (If-None-Match → 304), but only if they revalidate: set Cache-Control: no-cache (store but always revalidate) or a short max-age for listing reads, and purge or version URLs on update. The seller dashboard should bypass shared caches entirely (private); public product pages can tolerate brief staleness.

Scoring: Strong: Correct status codes (428/412/304), strong vs weak comparison, partial updates with exclude_unset, 412 returns the current ETag, and recognises that check-then-write needs to be atomic: lock here, conditional UPDATE in a real DB. Covers the 412 UX and multi-pod correctness. Acceptable: Implements If-Match/412 with versions, but treats a missing If-Match as allowed or misses the thread race until asked. Red flag: Compares updated_at timestamps sent by the client, uses last-write-wins and calls it fine, or returns 409/200 with an error flag instead of standard preconditions without a reason.

SR-FS-C02 · Resumable Server-Sent Events with Last-Event-ID and safe framing

Hard · Coding · Senior (5–8 years) · Server-Sent Events, reconnection, Last-Event-ID, protocol framing, injection, bounded buffers, real-time UI · 30 min

Problem:

The order-tracking page of a grocery app shows live updates ("packed", "out for delivery", chat with the rider) over Server-Sent Events. Mobile browsers drop connections constantly; EventSource reconnects automatically and sends a Last-Event-ID header with the last id it received. Users report two bugs: after a reconnect, updates are missing or duplicated; and a rider typed a chat message that made the page show a fake "refund approved" event.

Write the server-side core (the FastAPI endpoint just streams what this returns, then live events):

  • sse_frame(event_id, event, data) -> str: one SSE frame: id: …, event: …, then one data: line per line of data (split on \r\n, \r or \n, the three line endings the SSE spec recognises), terminated by a blank line.
  • EventLog(retention): keeps the last retention events in memory; append(event, data) -> int assigns ids 1, 2, 3, …; event names must be non-empty and single-line (ValueError otherwise).
  • frames_since(last_event_id) returns the frames a reconnecting client must receive:
  • None (fresh connection): nothing; the page loads current state via REST first.
  • A valid id: every retained event after it, in order (no duplicates, nothing missed).
  • If the client has missed events that were already dropped from retention, or the id is in the future (the server restarted and ids began again), negative, or not an integer: a single reset frame (event: reset, data: resync) carrying the latest id, telling the client to refetch state.
  • retention < 1 raises ValueError.

Signature: def sse_frame(event_id: int, event: str, data: str) -> str: ... class EventLog: def __init__(self, retention: int) -> None: ... def append(self, event: str, data: str) -> int: ... def frames_since(self, last_event_id: str | None) -> list[str]: ...

Constraints: Standard library only. Memory per stream is bounded by retention. Data is free text from users and staff (chat), so it may contain any characters, including blank lines.

Examples:

  • sse_frame(2, 'order', 'a\nb') → 'id: 2\nevent: order\ndata: a\ndata: b\n\n'
  • retention=3, ids 1..4 appended; frames_since('1') → frames for 2, 3, 4 (event 2 is still retained, so nothing was missed)
  • same log; frames_since('0') → ['id: 4\nevent: reset\ndata: resync\n\n'] (event 1 was dropped)
  • a chat message 'hi\n\nid: 999\nevent: payment\ndata: refund approved' → one chat event whose data is that exact text; no 'payment' event is created

Hints:

  1. How does an SSE client decide where one event ends? What does a blank line inside data do if you write it out verbatim?
  2. The client sends the id of the last event it received. Should the replay include that event?
  3. With retention, the oldest retained id is oldest. Which values of last_event_id mean the client is missing something you no longer have?

Reference solution:

import re
from collections import deque
from dataclasses import dataclass

_LINE_BREAK = re.compile(r"\r\n|\r|\n")       # the three line endings the SSE spec recognises


@dataclass(frozen=True)
class StoredEvent:
    id: int
    event: str
    data: str


def sse_frame(event_id: int, event: str, data: str) -> str:
    lines = [f"id: {event_id}", f"event: {event}"]
    lines += [f"data: {part}" for part in _LINE_BREAK.split(data)]   # one data: line per line
    return "\n".join(lines) + "\n\n"


class EventLog:
    """Recent events for one user's notification stream, replayable by Last-Event-ID."""

    def __init__(self, retention: int) -> None:
        if retention < 1:
            raise ValueError("retention must be >= 1")
        self._events: deque[StoredEvent] = deque(maxlen=retention)
        self._last_id = 0

    @property
    def last_id(self) -> int:
        return self._last_id

    def append(self, event: str, data: str) -> int:
        if not event or _LINE_BREAK.search(event):
            raise ValueError("event name must be non-empty and single-line")
        self._last_id += 1
        self._events.append(StoredEvent(self._last_id, event, data))
        return self._last_id

    def frames_since(self, last_event_id: str | None) -> list[str]:
        """Frames a reconnecting client must receive before live events resume."""
        if last_event_id is None:
            return []
        try:
            last = int(last_event_id)
        except ValueError:
            return [self._reset()]
        oldest = self._events[0].id if self._events else self._last_id + 1
        if last > self._last_id or last < oldest - 1 or last < 0:
            return [self._reset()]                 # ids from another epoch, or we dropped events
        return [sse_frame(e.id, e.event, e.data) for e in self._events if e.id > last]

    def _reset(self) -> str:
        # Tell the client to refetch state over REST; moving its id to now avoids a reset loop.
        return sse_frame(self._last_id, "reset", "resync")

Complexity: append is O(1) (a deque with maxlen drops the oldest automatically). frames_since is O(r + total data size) for r retained events; with ids contiguous you could index directly as last - oldest + 1 for O(k) in the number of events returned. Memory is O(retention) events per stream.

Tests:

import re

def parse(stream: str) -> list[tuple[str, str, str]]:
    """Minimal client-side SSE parser (what EventSource does), returning (id, event, data)."""
    out, cur_id, event, data = [], "", "message", []
    for line in re.split(r"\r\n|\r|\n", stream):
        if line == "":
            if data:
                out.append((cur_id, event, "\n".join(data)))
            event, data = "message", []
            continue
        field, _, value = line.partition(":")
        value = value.removeprefix(" ")
        if field == "id":
            cur_id = value
        elif field == "event":
            event = value
        elif field == "data":
            data.append(value)
    return out


log = EventLog(retention=3)
assert log.frames_since(None) == [] and log.frames_since("0") == []
i1 = log.append("order", "Order #A12 packed")
i2 = log.append("order", "Out for delivery\nRider: Imran (+91 98xxxx1234)")
i3 = log.append("chat", "line one\r\nline two\rline three\n")
assert (i1, i2, i3) == (1, 2, 3)

# Multi-line data round-trips through a real parser.
assert sse_frame(2, "order", "a\nb") == "id: 2\nevent: order\ndata: a\ndata: b\n\n"
events = parse("".join(log.frames_since("0")))
assert events == [("1", "order", "Order #A12 packed"),
                  ("2", "order", "Out for delivery\nRider: Imran (+91 98xxxx1234)"),
                  ("3", "chat", "line one\nline two\nline three\n")], events

# Resume after the last id the client saw: no duplicates, nothing missed.
assert parse("".join(log.frames_since("1"))) == events[1:]
assert log.frames_since("3") == []

# User-controlled text cannot forge events.
log.append("chat", "hi\n\nid: 999\nevent: payment\ndata: refund approved")
got = parse("".join(log.frames_since("3")))
assert got == [("4", "chat", "hi\n\nid: 999\nevent: payment\ndata: refund approved")], got

# Retention 3 now holds ids 2..4. A client at 1 has missed nothing (2 is still here)...
assert [e[0] for e in parse("".join(log.frames_since("1")))] == ["2", "3", "4"]
# ...but a client at 0 missed event 1, which was dropped: it must resync.
reset = log.frames_since("0")
assert reset == ["id: 4\nevent: reset\ndata: resync\n\n"], reset
# Ids from the future (server restarted, ids began again) or garbage also resync.
assert parse(log.frames_since("50")[0]) == [("4", "reset", "resync")]
assert parse(log.frames_since("abc")[0]) == [("4", "reset", "resync")]
assert parse(log.frames_since("-1")[0]) == [("4", "reset", "resync")]

for bad in ["", "order\nevent: payment", "x\ry"]:
    try:
        log.append(bad, "data")
    except ValueError:
        pass
    else:
        raise AssertionError(f"accepted event name {bad!r}")
try:
    EventLog(0)
except ValueError:
    pass
else:
    raise AssertionError("retention 0 accepted")

# Empty log: a client claiming id 5 resyncs to id 0.
assert EventLog(5).frames_since("5") == ["id: 0\nevent: reset\ndata: resync\n\n"]

A common wrong solution (the tests catch it):

import re
from collections import deque
from dataclasses import dataclass

_LINE_BREAK = re.compile(r"\r\n|\r|\n")       # the three line endings the SSE spec recognises


@dataclass(frozen=True)
class StoredEvent:
    id: int
    event: str
    data: str


def sse_frame(event_id: int, event: str, data: str) -> str:
    lines = [f"id: {event_id}", f"event: {event}"]
    lines.append(f"data: {data}")
    return "\n".join(lines) + "\n\n"


class EventLog:
    """Recent events for one user's notification stream, replayable by Last-Event-ID."""

    def __init__(self, retention: int) -> None:
        if retention < 1:
            raise ValueError("retention must be >= 1")
        self._events: deque[StoredEvent] = deque(maxlen=retention)
        self._last_id = 0

    @property
    def last_id(self) -> int:
        return self._last_id

    def append(self, event: str, data: str) -> int:
        if not event or _LINE_BREAK.search(event):
            raise ValueError("event name must be non-empty and single-line")
        self._last_id += 1
        self._events.append(StoredEvent(self._last_id, event, data))
        return self._last_id

    def frames_since(self, last_event_id: str | None) -> list[str]:
        """Frames a reconnecting client must receive before live events resume."""
        if last_event_id is None:
            return []
        try:
            last = int(last_event_id)
        except ValueError:
            return [self._reset()]
        oldest = self._events[0].id if self._events else self._last_id + 1
        if last > self._last_id or last < oldest - 1 or last < 0:
            return [self._reset()]                 # ids from another epoch, or we dropped events
        return [sse_frame(e.id, e.event, e.data) for e in self._events if e.id > last]

    def _reset(self) -> str:
        # Tell the client to refetch state over REST; moving its id to now avoids a reset loop.
        return sse_frame(self._last_id, "reset", "resync")

Alternatives and tradeoffs:

  • JSON-encode data: json.dumps escapes newlines, so one data: line is always enough and the client does JSON.parse(e.data). Most production APIs do this; the line-splitting is still required for any raw text field and is what the spec mandates.
  • WebSockets give bidirectional messaging but no built-in resume: you implement ids, replay and heartbeats yourself. SSE gets reconnection and Last-Event-ID from the browser for free, works over plain HTTP/2, and is enough for server-to-client updates.
  • Durable log instead of per-process memory: with several API pods, a reconnect may land on a different pod. Keep the per-order event log in Redis Streams (XADD with MAXLEN, XRANGE from the last id) or the database, so any pod can replay.
  • Long polling with ?after=<id> is the fallback for proxies that buffer streaming responses.

Follow-ups:

  1. "Users behind a corporate proxy see updates arrive in bursts every few minutes. What's going on, and what do you change?" The proxy (or nginx in front of the app) buffers the response. Send X-Accel-Buffering: no for nginx, Cache-Control: no-cache, avoid gzip on the stream, and send a comment heartbeat (: ping\n\n) every 15–30 s, which also keeps idle connections from being killed by load balancer timeouts.
  2. "Each pod holds 20,000 open SSE connections. What does that mean for your Python process model?" Use an async server (Uvicorn) so connections are cheap coroutines, not threads; never do blocking work in the stream generator; fan out from Redis pub/sub to local subscribers per pod; raise file-descriptor limits; and plan for reconnect storms after a deploy (jittered retry: field, staggered pod restarts, replay served from Redis, not the primary DB).
  3. "The event log is per order. How do you make sure a user can only subscribe to their own order's stream?" Authenticate the SSE request like any other (cookie session; EventSource can't set custom headers, so token-in-header schemes need a cookie or a short-lived signed URL token), check the order belongs to the user before streaming, and re-check on reconnect. Never put a long-lived bearer token in the query string where it ends up in logs.

Scoring: Strong: Splits data on all three line endings, explains the forged-event bug as protocol injection, replays strictly after Last-Event-ID, detects gaps with the off-by-one right, resets on future/garbage ids with the latest id to avoid reset loops. Discusses multi-pod replay and proxy buffering. Acceptable: Correct resume logic and framing for \n, but misses \r or the gap/future-id cases until prompted. Red flag: Writes data verbatim, replays including the last-seen event, or proposes resending the full history on every reconnect.

SR-FS-D01 · Bulk catalogue upload with validation preview, progress and undo

Medium · Design · Senior (5–8 years) · file uploads, background jobs, validation UX, progress reporting, idempotency, CSV injection, security, FastAPI · 40 min

Scenario:

Sellers on a marketplace manage catalogues of up to 50,000 products. They want to download their catalogue as CSV/XLSX, edit prices and stock in Excel, and upload it back. Today the upload is a synchronous FastAPI endpoint that parses the file and updates rows one by one: big files time out at the load balancer's 60 s limit after updating some rows, sellers retry and get a mix of old and new prices, and error messages are a single "Row 18,213 invalid".

On a sale day, ~3,000 sellers upload in the same hour. Design the end-to-end feature: upload, validation, the preview screen the seller sees, applying changes, progress, and undo. The frontend is React; the backend is Python (FastAPI) with Postgres and object storage.

Clarifying questions:

  • Which fields can be edited in bulk (price, stock, title, images)? Are some fields restricted, e.g. price changes above 50% need approval? (Price, stock, title, status; price drops above 50% need confirmation.)
  • Should a file with some invalid rows be applied partially, or all-or-nothing? (Seller chooses after the preview: apply valid rows only, or fix and re-upload.)
  • How fast must changes go live, and are there downstream consumers (search index, pricing cache) that need events? (Within minutes; search and the price cache consume change events.)
  • Do sellers edit through the UI and API at the same time as an upload? (Yes; their ops team and integrations both write.)
  • Max file size and formats? (CSV and XLSX, up to 20 MB.)
  • How long must undo be available? (24 hours, for the whole upload.)

Reference design:

Flow: upload → validate (async) → preview → confirm → apply (async) → done, with undo. Each upload is an import_job row with a state machine: uploaded → validating → ready_for_review → applying → applied | failed | reverted.

1. Upload. The browser gets a pre-signed URL and uploads directly to object storage (no 20 MB body through the API process), then calls POST /imports with the object key and an idempotency key, so a double-clicked or retried confirm creates one job. The API checks size and content type and enqueues validation.

2. Validation in a worker. A background worker (Celery/RQ/Arq, or a simple Postgres-backed job queue) streams the file: csv module with explicit encoding handling (UTF-8 with BOM, Excel's cp1252 fallback), or openpyxl in read-only mode for XLSX, with limits on rows, columns and cell length (an XLSX is a zip: cap uncompressed size to avoid zip bombs). Each row is parsed through a Pydantic model (prices as Decimal → integer paise, stock as non-negative int, SKU must belong to this seller) and compared to the current catalogue to produce a diff: unchanged / changed fields / errors / warnings (e.g. price drop > 50%). Results go to a import_rows table in batches; progress is updated every N rows.

3. Preview UX. The seller sees counts ("48,210 unchanged, 1,702 changes, 88 errors, 12 need confirmation"), a paginated, filterable table of changes with old → new values, and errors per row and column with the reason, plus a downloadable error file (their original rows with an error column) to fix and re-upload. Nothing has changed in the catalogue yet.

4. Apply. On confirm, a worker applies the diff in chunks of ~500 rows per transaction using conditional updates (UPDATE products SET price = :new WHERE id = :id AND version = :seen_version), so a product edited through the UI after validation is reported as a conflict rather than silently overwritten. Each chunk records before-values in an import_changes table (for undo) and emits change events via an outbox table for search and caches. The job is resumable: chunks are idempotent (keyed by job and row), so a worker crash resumes from the last committed chunk.

5. Progress. The UI polls GET /imports/{id} every 2 s (or listens on SSE) for state, processed/total and counts. Polling a small status row is cheap and robust; real-time push is optional here.

6. Undo. Within 24 h, POST /imports/{id}/revert creates a reverse job: for each changed row, restore the before-value only if the product still has the value this import set (version check); otherwise list it as "changed since, not reverted". It's another import job and reuses the same apply machinery.

7. Download/export safety. When generating CSV/XLSX exports, neutralise cells starting with =, +, -, @, tab or CR (prefix with ') to prevent CSV/formula injection, since product titles are user-controlled and the file is opened in Excel. Exports are generated by a worker and delivered by pre-signed URL with a short expiry.

8. Capacity and fairness. 3,000 uploads/hour is ~1 per second; validation of 50k rows is a few seconds of CPU in Python, so a modest worker pool suffices, but use per-seller concurrency limits (one active import per seller) and a fair queue so one seller with 20 uploads doesn't starve others. Track queue wait time as the main SLO.

Trade-offs:

  • Validate-then-apply (two steps) vs apply directly. Two steps cost an extra click and storage for the diff, but give the seller a preview, remove partial-failure confusion, and make conflicts visible. For a 3-row edit, inline editing in the UI is better; bulk is where the preview pays off.
  • All-or-nothing vs chunked apply. One giant transaction over 50k rows holds locks and blocks UI edits; chunked transactions with per-chunk idempotency and an undo log give practical atomicity ("applied" or "revertible") without long locks.
  • Polling vs SSE/WebSocket for progress. Polling a status endpoint every few seconds is simple, cache-friendly, and survives deploys; push is nicer but adds connection management for a feature used briefly. Start with polling.
  • Celery vs a lightweight Postgres-backed queue. Celery brings a broker and operational overhead; at ~1 job/s, a Postgres SKIP LOCKED queue (or Arq on existing Redis) is enough and keeps job state transactional with the data.

Deep-dive probes:

  • "A seller uploads the same file twice in a row by accident." Look for: idempotency key on job creation, and if two jobs exist, the second's diff is empty after the first applies (since it compares against current values), so applying it is harmless.
  • "Excel turned SKU 00123 into 123 and dates into serials." Look for: matching SKUs as strings, exporting with text formatting for ID columns in XLSX, warning when a SKU doesn't exist rather than creating it, and testing with real Excel round-trips.
  • "The worker crashes after applying 30,000 of 50,000 rows." Look for: chunk-level commits with an idempotent chunk key, resume from the job's checkpoint, the job state visible to the seller as 'applying (resumed)', and undo still able to revert exactly the applied chunks.
  • "Search shows the old price for 20 minutes after the import." Look for: outbox events emitted in the same transaction as each chunk, consumer lag monitoring, and batching of index updates; invalidating the price cache per changed product.

Rubric: Strong: Direct-to-storage upload with idempotent job creation; async streaming validation with Pydantic and a per-row/column diff preview; chunked, idempotent, conflict-aware apply with an undo log and outbox events; polling progress; formula-injection-safe exports and upload limits; per-seller fairness. Explains why the synchronous endpoint produced mixed prices. Acceptable: Moves processing to a background job with progress and error reporting, but applies without preview or conflict checks, or treats undo and export safety as afterthoughts. Red flag: Keeps the synchronous endpoint with a longer timeout, applies in one giant transaction, or reports only the first error.

SR-FS-O01 · CORS wildcard plus credentials reflects any origin

Medium & Tricky · Output · Senior (5–8 years) · CORS, FastAPI, Starlette middleware, cookies, security, same-origin policy · 8 min

Question: The React app moved to app.kiranakart.in and the API stayed on api.kiranakart.in, so a developer "fixed CORS" with a wildcard and credentials. You are reviewing it and compare it with an explicit allowlist. A page on an attacker's domain makes a credentialed fetch to the API. What does this print?

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient


def make_app(origins):
    app = FastAPI()
    app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True,
                       allow_methods=["*"], allow_headers=["*"])

    @app.get("/api/me")
    def me():
        return {"user": "priya", "wallet_paise": 125_000}

    return TestClient(app)


evil = "https://free-ipl-tickets.example"
for label, origins in [("wildcard", ["*"]), ("allowlist", ["https://app.kiranakart.in"])]:
    client = make_app(origins)
    r = client.get("/api/me", headers={"Origin": evil, "Cookie": "session=abc"})
    print(label, r.status_code, r.headers.get("access-control-allow-origin"),
          r.headers.get("access-control-allow-credentials"))
    pre = client.options("/api/me", headers={
        "Origin": evil,
        "Access-Control-Request-Method": "DELETE",
        "Access-Control-Request-Headers": "x-csrf-token",
    })
    print(label, "preflight", pre.status_code, pre.headers.get("access-control-allow-origin"))

Expected output:

wildcard 200 https://free-ipl-tickets.example true
wildcard preflight 200 https://free-ipl-tickets.example
allowlist 200 None true
allowlist preflight 400 None

The trap: "wildcard 200 * true, which browsers reject, so it's safe", and "allowlist 403" for the attacker's request. The first comes from knowing the spec rule that * can't be combined with credentials; the second from thinking CORS is server-side access control.

Explanation:

Wildcard + credentials. Browsers refuse Access-Control-Allow-Origin: * on a credentialed request. To make allow_origins=["*"] "work" with allow_credentials=True, Starlette (which FastAPI's CORSMiddleware is) echoes the request's Origin back and adds Access-Control-Allow-Credentials: true. The same happens on the preflight, which approves DELETE with a custom header. The effect: every website is trusted to make authenticated calls and read the responses. A page on free-ipl-tickets.example can read Priya's wallet balance with her cookie, and call state-changing endpoints too. This configuration is effectively "disable the same-origin policy for logged-in users".

Allowlist. The attacker's origin is not listed, so no Access-Control-Allow-Origin header is sent, and the browser hides the response from the attacker's script. Note what CORS does not do: - The simple GET still returns 200 and the handler ran. CORS is enforced by the browser on reading responses; it is not authorisation. A "simple" cross-site POST (form-encoded, no custom headers) also reaches the handler, which is why state-changing endpoints still need CSRF protection (SameSite cookies, a CSRF token header that forces a preflight). - The preflight for a non-simple request fails with 400, so the browser never sends the DELETE.

Mitigations a senior FS engineer layers: an explicit origin allowlist (from config per environment, no regex like .*kiranakart.* that also matches kiranakart.in.evil.com); SameSite=Lax or Strict session cookies (they stop the browser attaching the cookie to cross-site fetch; but a team that enabled credentialed CORS often also set SameSite=None, re-opening the hole); CSRF tokens on mutations; and a test in CI asserting that an unknown origin gets no Access-Control-Allow-Origin.

Takeaway: allow_origins=["*"] with allow_credentials=True reflects any origin and lets any site read authenticated responses; list origins explicitly, and remember CORS only controls what the browser lets scripts read.

Follow-ups:

  1. "Why doesn't the same problem exist when allow_credentials=False?" Then the middleware sends * and the browser will not attach cookies or Authorization from the page's credentials (a fetch with credentials: 'include' fails the CORS check against *). Anonymous public data being readable cross-origin is usually fine; the danger is ambient credentials.
  2. "The frontend and API share a registrable domain (kiranakart.in). Does SameSite=Lax fully protect you here?" No: SameSite is about site, not origin. A compromised or user-content subdomain like blog.kiranakart.in is same-site, so cookies are attached. You still need an exact origin allowlist and CSRF tokens; and keep user-generated content on a separate registrable domain.
  3. "How would you avoid this class of bug across 30 services?" A shared middleware/config package with a typed allowed_origins setting that rejects * when credentials are on (fail at startup), an integration test template every service runs, and a security scanner or contract test that sends a foreign Origin to each deployed service and alerts on reflection.

Scoring: Strong: All four lines exactly, including the reflected origin and the 200 on the allowlisted app. Explains that reflection makes any site a trusted reader of authenticated responses, that CORS is browser-side read control rather than authorisation, and layers allowlist + SameSite + CSRF. Acceptable: Gets the reflected origin with a nudge about what browsers do with * + credentials; expects 403 for the allowlist case but corrects after discussing where CORS is enforced. Red flag: Treats CORS as server-side protection, or 'fixes' the reflected origin by catching it in the frontend.

Code-review exercises

SR-CR-01 · Order search for the customer portal, with logging "for debugging"

Easy · Code review · Senior (5–8 years) · security, SQL injection, LIKE escaping, secrets in logs, PII, sqlite3, code review · 15 min

Context:

PR #1184 · portal/search.py · author: Karthik · reviewers: you

Customers of an online grocery store want to search their own past orders by item name ("pickle", "basmati"). The FastAPI route (not shown) takes customer_email from the authenticated session and query from the search box, and passes the raw request headers along so the function can log them.

Karthik's description:

Adds search_orders(). Substring match on the item name, newest-first ordering is a follow-up. I log the full request so on-call can reproduce bad searches. Capped at MAX_RESULTS rows, as agreed with the DBA, because some B2B customers have 40,000+ orders.

Production uses Postgres through a DB-API driver; the tests use sqlite3, which has the same placeholder semantics for this code. Review it as the senior engineer who owns the portal backend.

Code under review:

# --- portal/search.py ---
import logging
import sqlite3

logger = logging.getLogger("portal.search")

MAX_RESULTS = 50


def search_orders(conn: sqlite3.Connection, headers: dict[str, str],
                  customer_email: str, query: str) -> list[tuple[int, str]]:
    """Return (order_id, item) for this customer's orders whose item contains `query`."""
    logger.info("order search headers=%s email=%s query=%r", headers, customer_email, query)
    sql = (
        "SELECT order_id, item FROM orders "
        f"WHERE customer_email = '{customer_email}' AND item LIKE '%{query}%' "
        "ORDER BY order_id"
    )
    return conn.execute(sql).fetchall()

What a strong reviewer raises:

  1. High: SQL injection through query (and customer_email). Both values are formatted into the SQL with an f-string. A search for x' OR '1'='1' -- turns the condition into customer_email = '…' AND item LIKE '%x' OR '1'='1' --%'; AND binds tighter than OR, so the OR '1'='1' branch matches every row in the table: one customer reads every other customer's orders. With a richer payload (UNION SELECT) they can read any table the DB user can. "The email comes from the session" doesn't make the email safe either: it is user-controlled at sign-up. The same bug also breaks honest searches: Haldiram's raises a syntax error, which the API turns into a 500. Fix: Use driver placeholders for every value (? for sqlite3, %s for psycopg) and pass a parameter tuple. Never build SQL with f-strings, % or .format() for values; identifiers that must vary (column names for sorting) come from an allow-list. Add a lint rule (Bandit B608, Ruff S608) so the pattern fails CI rather than relying on review.
  2. High: Credentials and PII in logs. headers includes Authorization: Bearer … and the session Cookie. Logs are shipped to a log platform that far more people (and vendors) can read than the production database, and are retained for months. Anyone with log access can replay a customer's session. Logging the email and raw query also puts personal data in a store that is outside the data-retention and deletion process (DPDP Act obligations in India). Fix: Redact sensitive headers by name, case-insensitively (an allow-list of safe headers is even safer), and log a request ID plus the shape of the query (query_len), not its content. Best done once, in a logging filter or middleware, so every endpoint is covered; and rotate the tokens that were already logged.
  3. Medium: MAX_RESULTS is declared but never applied. The description promises a cap, but the SQL has no LIMIT. A B2B customer with 40,000 orders searching "rice" pulls every row into memory and into the JSON response: slow responses, memory spikes on the API pods, and an easy way to load the database on purpose. Fix: LIMIT ? in SQL (not slicing in Python after fetching everything), and clamp any caller-supplied limit to the maximum. For real pagination, use keyset pagination on order_id.
  4. Low: % and _ from the search box are treated as wildcards. Even after parameterising, a search for 50% matches "Mango pickle 500g", and a search of just % or _ matches everything. It's a correctness bug and a cheap way to force full scans. Fix: Escape \, %, _ in the user's text and add ESCAPE '\' to the LIKE. Mention that a leading % can't use a B-tree index, so at scale this wants a trigram index (pg_trgm) or a search engine.

Tests that expose the bug:

# --- tests/test_search.py ---
captured = []


class ListHandler(logging.Handler):
    def emit(self, record):
        captured.append(record.getMessage())


logger.addHandler(ListHandler())
logger.setLevel(logging.INFO)
logger.propagate = False

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (order_id INTEGER PRIMARY KEY, customer_email TEXT, item TEXT)")
conn.executemany("INSERT INTO orders VALUES (?, ?, ?)", [
    (1, "priya@example.in", "Mango pickle 500g"),
    (2, "priya@example.in", "Mango pickle 50% off pack"),
    (3, "priya@example.in", "Haldiram's bhujia 1kg"),
    (4, "rahul@example.in", "Lime pickle 1kg"),
    (5, "rahul@example.in", "Silk saree"),
])
conn.executemany("INSERT INTO orders VALUES (?, ?, ?)",
                 [(100 + i, "b2b@kirana.example.in", f"Basmati rice 25kg lot {i}") for i in range(80)])
headers = {"Authorization": "Bearer sk_live_9f8e7d6c", "Cookie": "session=abc123xyz",
           "User-Agent": "Mozilla/5.0"}

# Happy path.
assert search_orders(conn, headers, "priya@example.in", "pickle") == [
    (1, "Mango pickle 500g"), (2, "Mango pickle 50% off pack")]

# A legitimate apostrophe must not break the query.
assert search_orders(conn, headers, "priya@example.in", "Haldiram's") == [(3, "Haldiram's bhujia 1kg")]

# Search-box input must never widen the result to other customers' orders.
leaked = search_orders(conn, headers, "priya@example.in", "x' OR '1'='1' --")
assert leaked == [], f"returned {len(leaked)} rows, including other customers' orders"

# % and _ typed by the user are literal characters, not wildcards.
assert search_orders(conn, headers, "priya@example.in", "50%") == [(2, "Mango pickle 50% off pack")]

# The agreed cap holds even for customers with thousands of orders.
assert len(search_orders(conn, headers, "b2b@kirana.example.in", "Basmati")) == MAX_RESULTS

# Credentials never reach the log pipeline.
assert captured, "search should still log something useful for on-call"
for line in captured:
    assert "sk_live_9f8e7d6c" not in line and "abc123xyz" not in line, line

Fixed version:

# --- portal/search.py ---
import logging
import sqlite3

logger = logging.getLogger("portal.search")

MAX_RESULTS = 50
_SENSITIVE_HEADERS = {"authorization", "cookie", "x-api-key", "proxy-authorization"}


def _redact(headers: dict[str, str]) -> dict[str, str]:
    # Header names are case-insensitive, so compare lower-cased.
    return {k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()}


def _escape_like(text: str) -> str:
    # Escape the escape character first, then the two LIKE wildcards.
    return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")


def search_orders(conn: sqlite3.Connection, headers: dict[str, str],
                  customer_email: str, query: str,
                  limit: int = MAX_RESULTS) -> list[tuple[int, str]]:
    """Return (order_id, item) for this customer's orders whose item contains `query`."""
    limit = max(1, min(limit, MAX_RESULTS))
    # Log what on-call needs (shape of the request), not credentials or the customer's identity.
    logger.info("order search headers=%s query_len=%d", _redact(headers), len(query))
    sql = (
        "SELECT order_id, item FROM orders "
        "WHERE customer_email = ? AND item LIKE ? ESCAPE '\\' "
        "ORDER BY order_id LIMIT ?"
    )
    return conn.execute(sql, (customer_email, f"%{_escape_like(query)}%", limit)).fetchall()

Follow-ups:

  1. "The author replies: "Parameterising is fine, but the email comes from our own session, so it's trusted." How do you respond in the review?" Trust is about where the bytes originally came from, not which variable holds them. The email was typed by a user at sign-up; if any path (OAuth provider, admin import, a future feature) stores a'--@x.in, this query breaks or leaks. Placeholders cost nothing, so the team standard should be "no string-built SQL with values, ever", enforced by a linter so it doesn't depend on judging trust case by case.
  2. "How would you make sure secrets never reach logs across 30 services, not just in this function?" Centralise it: a shared logging config with a logging.Filter (or structlog processor) that redacts known keys (authorization, cookie, password, token, PAN/Aadhaar patterns) before any handler sees the record; middleware that logs requests through an allow-list of headers; secret-scanning on the log pipeline with alerts; and a test that logs a fake token through the real config and asserts it is redacted. Include rotation in the runbook for when a leak is found anyway.
  3. "Is a LIKE '%term%' search acceptable at 50 million order rows? What would you propose?" Not on a hot path: a leading wildcard prevents B-tree index use, so each search scans the customer's rows (fine if customer_email is indexed and per-customer counts are small) or the whole table (not fine). Options: a composite index on (customer_email, order_id) so the scan is per customer; a pg_trgm GIN index for substring search; or a search service (OpenSearch) fed by change data capture if ranking and typo tolerance matter. Choose based on per-customer cardinality and the latency target.
  4. "Which tests would you ask the author to add so this class of bug can't come back?" Tests with hostile inputs (quotes, --, %, _, backslash, very long strings) asserting results stay within the caller's own rows; a test that a B2B account's search is capped at the limit; a test that captures log output (pytest's caplog) and asserts no token or cookie appears. Plus a static-analysis rule in CI, because a test covers one function while a lint rule covers all of them.

Scoring: Strong: Spots the injection immediately, demonstrates the exploit (why OR defeats the email filter), and rejects "the email is trusted". Raises the token-in-logs issue as equally serious and talks about rotation and a central redaction filter. Notices the unused MAX_RESULTS, fixes with LIMIT ?, and mentions LIKE wildcard escaping. Proposes linters and tests that enforce the standard team-wide. Acceptable: Finds the injection and fixes it with placeholders, and finds the logged Authorization header once asked to look at the logging line. Misses the wildcard escaping or the missing LIMIT without a prompt. Red flag: Fixes injection by escaping quotes by hand (query.replace("'", "''")) or by validating with a regex instead of placeholders. Sees nothing wrong with logging full headers "because logs are internal". Comments mainly on naming and type hints. At five-plus years, missing SQL injection in a string-built query is disqualifying for a reviewer role.

SR-CR-02 · Upcasting v1 order events to a v2 data contract

Medium · Code review · Senior (5–8 years) · data contracts, schema evolution, pydantic v2, money as integers, time zones, tolerant reader · 20 min

Context:

PR #412 · contracts/order_events.py · author: Sneha · reviewers: you (owner of the revenue pipeline)

The order-placed Kafka topic carries two versions of the same event while the checkout team migrates:

  • v1 (old monolith, still ~30% of traffic): {"order_id": "OD-1001", "amount": 19.99, "placed_at": "2026-03-31 23:45:00"}. amount is in rupees as a JSON float; placed_at is the IST wall-clock time with no offset (the monolith always ran on IST servers).
  • v2 (new checkout service): {"schema_version": 2, "order_id": "OD-1002", "amount_paise": 1999, "placed_at": "2026-03-31T18:15:00+00:00"}.

The consumer validates every message into OrderPlaced (always v2 shape) and writes it to the warehouse, where finance builds daily revenue by IST business date. The consumer keeps the raw dict so that a message that fails later in the pipeline can be sent to the dead-letter topic unchanged.

Sneha's description: "Single model for both versions; a before validator upcasts v1. extra="forbid" so we catch contract drift early. Frozen so nobody mutates events downstream."

Code under review:

# --- contracts/order_events.py ---
from datetime import date, datetime, timedelta, timezone

from pydantic import BaseModel, ConfigDict, ValidationError, model_validator

IST = timezone(timedelta(hours=5, minutes=30))


class OrderPlaced(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)

    schema_version: int = 2
    order_id: str
    amount_paise: int
    placed_at: datetime

    @model_validator(mode="before")
    @classmethod
    def upcast_v1(cls, data):
        if isinstance(data, dict) and "amount" in data:
            data["amount_paise"] = int(data.pop("amount") * 100)
            data["schema_version"] = 2
            data["placed_at"] = datetime.fromisoformat(data["placed_at"]).replace(tzinfo=timezone.utc)
        return data


def ist_business_date(event: OrderPlaced) -> date:
    return event.placed_at.astimezone(IST).date()

What a strong reviewer raises:

  1. High: Money through a float, then truncated. 19.99 * 100 is 1998.9999999999998 in binary floating point, and int() truncates toward zero, so ₹19.99 becomes 1998 paise; ₹0.29 becomes 28. Every affected order is under-reported by a paisa. Small per row, but across lakhs of orders the revenue table no longer reconciles with the payment gateway's settlement file, and finance opens an incident. Fix: Convert through Decimal(str(value)) (which recovers the shortest decimal text, '19.99'), multiply by 100, and reject anything that isn't a whole number of paise instead of rounding silently. Once the event is v2, keep money as integer paise end to end.
  2. High: Wrong time zone for v1. The v1 contract says placed_at is IST wall-clock time, but the upcaster stamps it as UTC. An order at 23:45 IST on 31 March becomes 23:45 UTC, which is 05:15 IST on 1 April, so it moves to the next business day and here into the next financial year. Every v1 order is shifted 5h30m; anything after 18:30 IST lands on the wrong date in the daily revenue report. Fix: replace(tzinfo=IST) for naive v1 values (that is the documented meaning), and convert to UTC only for storage if wanted. Write the assumption into the contract docs and add a boundary test at 23:45 IST.
  3. Medium: extra="forbid" on the consumer turns every additive producer change into an outage. When the checkout team adds an optional coupon_code, every new message fails validation, the consumer stops or dead-letters 100% of traffic, and revenue goes missing until someone redeploys. Adding an optional field should be a backward-compatible change. Fix: Consumers use extra="ignore" (tolerant reader); strictness belongs at the producer boundary and in a schema registry/compatibility check in CI (for example, a test that validates sample messages from every producer version). Raise "catch drift early" as a CI concern, not a runtime one.
  4. Medium: The validator mutates the caller's dict. data.pop("amount") and the assignments rewrite the consumer's raw message, so the dead-letter topic receives a half-upcast payload (amount_paise and a datetime object that isn't even JSON-serialisable) instead of what was received, which makes replay and debugging unreliable. It also makes validation non-idempotent: validating the same dict twice takes a different branch the second time. Fix: Copy first (data = dict(data)). A before validator should treat its input as read-only.
  5. Medium: v2 accepts naive datetimes. placed_at: datetime lets a v2 producer send "2026-04-02T10:00:00"; pydantic produces a naive datetime, and astimezone() then interprets it in the consumer host's local time zone, so the result depends on where the consumer runs. Fix: AwareDatetime for the field so a missing offset is a validation error at the contract boundary.
  6. Low: Version detection by "amount is present" is fragile: a v2 producer that also sends a legacy amount (common during a migration) would be upcast again and overwrite amount_paise. Fix: Branch on schema_version (absent means v1), and log/metric the count of v1 messages so the team knows when v1 can be removed.

Tests that expose the bug:

# --- tests/test_order_events.py ---
# v1: ₹19.99 must be exactly 1999 paise, not "about" 1999.
raw_v1 = {"order_id": "OD-1001", "amount": 19.99, "placed_at": "2026-03-31 23:45:00"}
ev = OrderPlaced.model_validate(raw_v1)
assert ev.amount_paise == 1999, ev.amount_paise
assert OrderPlaced.model_validate({"order_id": "OD-1003", "amount": 0.29,
                                   "placed_at": "2026-04-01 09:00:00"}).amount_paise == 29

# v1 timestamps are IST wall-clock: 23:45 on 31 March is still FY 2025-26.
assert ist_business_date(ev) == date(2026, 3, 31), ist_business_date(ev)
assert ev.placed_at == datetime(2026, 3, 31, 18, 15, tzinfo=timezone.utc)

# The consumer's raw message must be left as it was received (for the dead-letter topic).
assert raw_v1 == {"order_id": "OD-1001", "amount": 19.99, "placed_at": "2026-03-31 23:45:00"}, raw_v1

# v2 passes through unchanged.
ev2 = OrderPlaced.model_validate({"schema_version": 2, "order_id": "OD-1002", "amount_paise": 1999,
                                  "placed_at": "2026-03-31T18:15:00+00:00"})
assert ev2.amount_paise == 1999 and ist_business_date(ev2) == date(2026, 3, 31)

# Producers add optional fields over time; consumers must keep working (tolerant reader).
ev3 = OrderPlaced.model_validate({"schema_version": 2, "order_id": "OD-1004", "amount_paise": 50000,
                                  "placed_at": "2026-04-02T10:00:00+05:30", "coupon_code": "DIWALI10"})
assert ev3.amount_paise == 50000

# A v2 producer that forgets the offset is a contract violation, not "assume UTC".
try:
    OrderPlaced.model_validate({"schema_version": 2, "order_id": "OD-1005", "amount_paise": 100,
                                "placed_at": "2026-04-02T10:00:00"})
except ValidationError:
    pass
else:
    raise AssertionError("naive v2 timestamp was accepted")

# Sub-paisa v1 amounts are rejected instead of silently truncated.
try:
    OrderPlaced.model_validate({"order_id": "OD-1006", "amount": 10.005, "placed_at": "2026-04-02 10:00:00"})
except ValidationError:
    pass
else:
    raise AssertionError("₹10.005 was accepted")

Fixed version:

# --- contracts/order_events.py ---
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal

from pydantic import AwareDatetime, BaseModel, ConfigDict, ValidationError, model_validator

IST = timezone(timedelta(hours=5, minutes=30))


class OrderPlaced(BaseModel):
    # Consumers ignore unknown fields (tolerant reader); producers are validated strictly on their side.
    model_config = ConfigDict(extra="ignore", frozen=True)

    schema_version: int = 2
    order_id: str
    amount_paise: int
    placed_at: AwareDatetime          # naive datetimes are rejected

    @model_validator(mode="before")
    @classmethod
    def upcast_v1(cls, data):
        # Decide on the version field, not on which keys happen to be present.
        if not isinstance(data, dict) or "schema_version" in data:
            return data
        data = dict(data)                                   # never mutate the caller's message
        rupees = Decimal(str(data.pop("amount")))           # str() keeps the decimal text: 19.99 -> '19.99'
        paise = rupees * 100
        if paise != paise.to_integral_value():
            raise ValueError(f"amount {rupees} has more than 2 decimal places")
        data["amount_paise"] = int(paise)
        naive = datetime.fromisoformat(data["placed_at"])
        if naive.tzinfo is None:
            naive = naive.replace(tzinfo=IST)               # v1 contract: IST wall-clock time
        data["placed_at"] = naive
        data["schema_version"] = 2
        return data


def ist_business_date(event: OrderPlaced) -> date:
    return event.placed_at.astimezone(IST).date()

Follow-ups:

  1. "Finance says the last three weeks of revenue are wrong. What's your plan beyond merging the fix?" Quantify the damage (count of v1 orders, and how many fall after 18:30 IST or have truncated paise) by re-validating the raw messages, which is possible only if raw events are retained (topic retention or a raw landing zone). Backfill the affected warehouse partitions with the fixed code idempotently (overwrite partitions by date, don't append), reconcile against the gateway settlement totals, and tell finance which dates changed, including the FY boundary. Add a daily reconciliation check so the next drift is caught in a day, not three weeks.
  2. "Where should the contract live so three producer teams and two consumer teams stay compatible?" In one versioned place: a schema registry (Avro/Protobuf/JSON Schema with compatibility rules), or a small shared package of pydantic models with semantic versioning. Producers run a CI check that new schemas are backward compatible with the last released one; consumers run contract tests against sample messages from each live producer version. The rules: add optional fields freely, never change the meaning or unit of an existing field (that's why it's amount_paise, a new name), and remove fields only after consumers stop reading them.
  3. "Why does Decimal(str(19.99)) work but Decimal(19.99) doesn't?" Decimal(19.99) converts the exact binary value of the float: 19.989999999999998436805981327779591083526611328125. str(19.99) gives the shortest decimal string that round-trips to the same float, '19.99', which is what the producer meant. Even better is receiving the amount as a string or integer paise in the first place, so no float ever touches money.
  4. "Should the consumer crash, skip, or dead-letter a message that fails validation?" Dead-letter it with the original bytes, the error and the consumer version, increment a metric, and alert on the rate, so one bad producer doesn't stop the partition (crashing blocks every message behind it) and the data isn't silently lost (skipping). If the failure rate jumps, for example because a new field broke validation, page someone: that's a contract break, not bad data.

Scoring: Strong: Finds the float truncation and explains it with the actual value (1998.999…), finds the UTC/IST mix-up and its date and financial-year impact, and pushes back on extra="forbid" for consumers with the tolerant-reader argument. Notices the mutation of the raw dict and the dead-letter consequence. Talks about backfilling and reconciliation, not just the code fix. Acceptable: Finds the money bug (suggests round() or Decimal) and the time zone bug, and accepts the extra="forbid" problem once the coupon-code scenario is described. Misses the dict mutation or the naive-v2 hole. Red flag: Suggests round(amount * 100) and keeping floats for money "because it's close enough". Thinks a naive timestamp "is UTC by default". Approves extra="forbid" on the consumer as "safer". Reviews only the shape of the pydantic code without asking how finance uses the data.

SR-CR-03 · Request coalescing in an async price cache

Medium & Tricky · Code review · Senior (5–8 years) · asyncio, caching, request coalescing, cancellation, asyncio.shield, TTL, failure handling · 20 min

Context:

PR #2290 · checkout/price_cache.py · author: Aditya · reviewers: you (owner of the checkout service)

Checkout is an async FastAPI service. Every cart render calls the internal pricing service (p50 ~300 ms) once per SKU. During flash sales the same few SKUs are requested thousands of times a second, and the pricing team has asked us to stop hammering them.

Aditya's description:

In-process async cache with a TTL (30 s, agreed with pricing). Concurrent requests for the same SKU are coalesced: the first caller starts the load as a task and everyone else awaits the same task, so a burst makes one upstream call. No lock needed because asyncio is single-threaded. Clock is injectable for tests.

Two facts about the environment: the API gateway cancels a request after 2 s (the handler sees CancelledError), and the pricing service returns occasional 503s during deploys.

Code under review:

# --- checkout/price_cache.py ---
import asyncio
import time


class AsyncTTLCache:
    def __init__(self, loader, ttl: float = 30.0, clock=time.monotonic):
        self._loader = loader              # async def loader(key) -> value
        self._ttl = ttl
        self._clock = clock
        self._values = {}                  # key -> (expires_at, value)
        self._inflight = {}                # key -> asyncio.Task

    async def get(self, key):
        hit = self._values.get(key)
        if hit and hit[0] > self._clock():
            return hit[1]
        task = self._inflight.get(key)
        if task is None:
            task = asyncio.create_task(self._loader(key))
            self._inflight[key] = task
        value = await task
        self._values[key] = (self._clock() + self._ttl, value)
        return value

What a strong reviewer raises:

  1. High: Finished tasks are never removed from _inflight. After the first load, the task stays in the dict forever. When the TTL expires, get finds that old, completed task and await task returns its old result immediately, so the cache never refreshes: the TTL check runs, but its only effect is to rewrite the expiry. A price change (or a flash-sale price ending) is never seen until the pod restarts. Worse, a failed task stays too: one 503 during a pricing deploy means every later request for that SKU re-raises the same ConnectionError until restart. Fix: Remove the entry when the load finishes, in a finally inside the task itself (so it happens even if every waiter has gone). Store the value inside the task too, so the cache is filled even when no caller is left to do it.
  2. High: One cancelled caller cancels the shared load for everyone. When a coroutine awaiting a task is cancelled, asyncio cancels the awaited task as well. The gateway's 2 s timeout cancels request A, which cancels the coalesced pricing call, and requests B..Z, which were sharing it, all get CancelledError, so one slow client turns into a burst of failures. The busier the SKU, the bigger the blast radius, which is the opposite of what coalescing is for. Fix: await asyncio.shield(task): the cancelled caller stops waiting, but the inner task keeps running for the others. This is why the task must own its own cleanup and cache write (previous issue).
  3. Medium: Unbounded memory and no negative caching or jitter. _values grows with every SKU ever seen (a crawler walking the catalogue fills it), and all entries loaded at the start of a sale expire in the same second, producing a synchronised miss storm every 30 s. After the fix, a pricing outage means every request retries upstream immediately. Fix: Bound the cache (an LRU of N entries, or evict expired entries on write), add a small random jitter to the TTL, and consider caching failures briefly (1–2 s) or serving the stale value on upstream error ("stale-if-error") with a metric.
  4. Low: if hit and … relies on the tuple being truthy; fine here, but the same habit breaks when a cached value is falsy (0, "") in a cache that stores bare values. Also no metrics: hit/miss/coalesced counts are what tell you whether the cache is doing its job. Fix: if hit is not None, and add counters for hits, misses, coalesced waits and load failures.

Tests that expose the bug:

# --- tests/test_price_cache.py ---
class FakeClock:
    def __init__(self):
        self.now = 1_000.0

    def __call__(self):
        return self.now


async def coalesces_a_burst():
    calls, gate = [], asyncio.Event()

    async def loader(sku):
        calls.append(sku)
        await gate.wait()
        return 49_900                      # paise

    cache = AsyncTTLCache(loader, ttl=30, clock=FakeClock())
    waiters = [asyncio.create_task(cache.get("SKU-1")) for _ in range(5)]
    await asyncio.sleep(0)
    gate.set()
    assert await asyncio.gather(*waiters) == [49_900] * 5
    assert calls == ["SKU-1"]


async def refreshes_after_ttl():
    clock, prices, calls = FakeClock(), iter([49_900, 52_900]), []

    async def loader(sku):
        calls.append(sku)
        return next(prices)

    cache = AsyncTTLCache(loader, ttl=30, clock=clock)
    assert await cache.get("SKU-2") == 49_900
    clock.now += 10
    assert await cache.get("SKU-2") == 49_900 and len(calls) == 1
    clock.now += 31                        # past the TTL: the price change must show up
    assert await cache.get("SKU-2") == 52_900, "stale price served after TTL"


async def retries_after_a_failure():
    attempts = []

    async def flaky(sku):
        attempts.append(sku)
        if len(attempts) == 1:
            raise ConnectionError("pricing-svc 503")
        return 19_900

    cache = AsyncTTLCache(flaky, ttl=30, clock=FakeClock())
    try:
        await cache.get("SKU-3")
    except ConnectionError:
        pass
    else:
        raise AssertionError("first call should fail")
    assert await cache.get("SKU-3") == 19_900, "one 503 must not poison the key"


async def one_cancelled_caller_does_not_fail_the_others():
    gate = asyncio.Event()

    async def slow(sku):
        await gate.wait()
        return 74_900

    cache = AsyncTTLCache(slow, ttl=30, clock=FakeClock())
    first = asyncio.create_task(cache.get("SKU-4"))
    others = [asyncio.create_task(cache.get("SKU-4")) for _ in range(2)]
    await asyncio.sleep(0)
    first.cancel()                         # gateway timeout on one request
    await asyncio.sleep(0)
    gate.set()
    results = await asyncio.gather(*others, return_exceptions=True)
    assert results == [74_900, 74_900], results
    assert first.cancelled()


async def main():
    await coalesces_a_burst()
    await refreshes_after_ttl()
    await retries_after_a_failure()
    await one_cancelled_caller_does_not_fail_the_others()


asyncio.run(asyncio.wait_for(main(), timeout=5))

Fixed version:

# --- checkout/price_cache.py ---
import asyncio
import time


class AsyncTTLCache:
    def __init__(self, loader, ttl: float = 30.0, clock=time.monotonic):
        self._loader = loader              # async def loader(key) -> value
        self._ttl = ttl
        self._clock = clock
        self._values = {}                  # key -> (expires_at, value)
        self._inflight = {}                # key -> asyncio.Task

    async def get(self, key):
        hit = self._values.get(key)
        if hit is not None and hit[0] > self._clock():
            return hit[1]
        task = self._inflight.get(key)
        if task is None:
            task = asyncio.create_task(self._load(key))
            self._inflight[key] = task
        # shield: a cancelled caller stops waiting, but the shared load keeps going for the others.
        return await asyncio.shield(task)

    async def _load(self, key):
        try:
            value = await self._loader(key)
            self._values[key] = (self._clock() + self._ttl, value)
            return value
        finally:
            # Success or failure, the load is over: the next miss starts a fresh one.
            self._inflight.pop(key, None)

Follow-ups:

  1. "Aditya says "no lock needed because asyncio is single-threaded". When is that true, and when would it stop being true?" It's true here because there is no await between checking _inflight and inserting the new task, so no other coroutine can run in between: the check-and-set is atomic with respect to the event loop. It stops being true if someone adds an await in that window (for example an async metrics call or an async Redis lookup before creating the task), or if the cache is shared with code running in threads (to_thread, a sync FastAPI route running in the thread pool). Then you need an asyncio.Lock per key, or, across threads, a threading.Lock.
  2. "With shield, if every caller is cancelled and the load then fails, what happens?" The task finishes with an exception that nobody retrieves, so asyncio logs "Task exception was never retrieved" when the task is garbage-collected. The cache itself is fine (the finally clears the entry), but the log is noisy. Add a done-callback that retrieves the exception (t.exception()) and records a metric, or keep a strong reference set of background tasks, which the docs also recommend so tasks aren't garbage-collected mid-flight.
  3. "We run 40 pods. Is an in-process cache still the right design, or should this be Redis?" In-process coalescing cuts upstream calls to at most one per SKU per pod per TTL, i.e. at most 40 per 30 s for a hot SKU, which is often plenty and costs no network hop. Redis gives one shared copy (consistent prices across pods, fewer upstream calls at large pod counts) but adds a dependency, a network round-trip and its own stampede problem (needs a lock or "early refresh"). A common answer is both: a small in-process L1 with a short TTL in front of a shared L2. The choice depends on how much price inconsistency between pods the business tolerates for 30 s.
  4. "How would you have caught the never-refreshes bug before production?" With exactly the test that was missing: inject the clock, advance it past the TTL, and assert a second upstream call and the new value. Tests that only cover "first call misses, second call hits" pass with this bug. More generally, every cache PR should have tests for expiry, failure, and concurrent misses, because those are the paths that break.

Scoring: Strong: Finds both high-severity bugs by reasoning about the task lifecycle: completed tasks stay in _inflight (stale forever, failures cached forever), and cancellation propagates into the awaited task. Fixes with shield plus cleanup inside the task, and explains why the cleanup has to move. Explains when the no-lock claim holds, and raises bounding, jitter and metrics. Acceptable: Finds the missing cleanup (at least the failure-poisoning half) and fixes it with try/finally in get. Gets to the cancellation problem once the gateway timeout is pointed out, even if unsure of the shield API. Red flag: Approves because "coalescing works" (the burst test passes). Proposes wrapping everything in one global asyncio.Lock, which serialises all SKUs behind the slowest load. Doesn't know that cancelling a waiter cancels the awaited task, or says CancelledError "can't happen in FastAPI".

SR-CR-04 · Batched backfill of a new paise column on a live payments table

Hard · Code review · Senior (5–8 years) · migrations, backfills, pagination, transactions, resumability, sqlite3, data quality · 25 min

Context:

PR #877 · migrations/0042_backfill_amount_paise.py · author: Meera · reviewers: you (owner of the payments DB)

Migration 0041 (already deployed) added a nullable amount_paise INTEGER column to payments, which has about 9 million rows. The old column amount_rupees is TEXT and was filled by several generations of code, so it contains values like "1299.50", "1,29,999.00" (Indian digit grouping from a CSV import in 2019) and a few junk values such as "N/A". This PR fills the new column so that 0043 can switch reads.

Meera's description:

Batches of 1,000 so we never lock the table for long. Ordered by id so paging is stable. Safe to rerun: it only touches rows where amount_paise IS NULL. on_batch reports progress to our metrics.

The deploy pipeline runs the script against production (Postgres) while the app is live; the pod running it can be killed by a node rotation at any point, and the pipeline then simply reruns it. The tests use sqlite3, whose connect() opens a transaction implicitly before the first UPDATE, just as a DB-API Postgres driver does.

Code under review:

# --- migrations/0042_backfill_amount_paise.py ---
import sqlite3
from decimal import Decimal

BATCH_SIZE = 1000


def to_paise(text: str) -> int:
    return int(Decimal(text) * 100)


def backfill_amount_paise(conn: sqlite3.Connection, batch_size: int = BATCH_SIZE, on_batch=None) -> int:
    offset = 0
    total = 0
    while True:
        rows = conn.execute(
            "SELECT id, amount_rupees FROM payments WHERE amount_paise IS NULL "
            "ORDER BY id LIMIT ? OFFSET ?",
            (batch_size, offset),
        ).fetchall()
        if not rows:
            break
        for payment_id, rupees in rows:
            conn.execute("UPDATE payments SET amount_paise = ? WHERE id = ?", (to_paise(rupees), payment_id))
        total += len(rows)
        offset += batch_size
        if on_batch:
            on_batch(total)
    conn.commit()
    return total

What a strong reviewer raises:

  1. High: OFFSET over a shrinking result set skips rows. The WHERE amount_paise IS NULL filter and the updates interact: after batch 1 fills ids 1–3, those rows leave the filtered set, so OFFSET 3 now skips ids 4–6 and the second batch processes 7–9. Roughly half of each pass is skipped; with 10 rows and batches of 3 only 6 rows are filled, and the loop ends because the offset passes the end. On 9M rows the job reports success with millions of NULLs left, and 0043 then switches reads to a column that is half empty. Fix: Keyset pagination: remember the last id and query WHERE id > :last_id … ORDER BY id LIMIT :n. It's correct under concurrent updates, and it's faster too: OFFSET n makes the database walk and discard n rows on every page, so a 9M-row backfill becomes quadratic.
  2. High: One transaction for the whole run. commit() is called once at the end, so all 9M updates sit in one transaction: row locks are held for the entire run (app writes to those rows block, and can time out), on Postgres the table bloats and vacuum can't clean up, replication lag spikes on commit, and when the pod is killed at row 8.9M, everything rolls back and the rerun starts from zero. The "batches so we never lock the table for long" claim is false: batching the reads doesn't batch the transactions. Fix: Commit after each batch, so each batch is a short transaction and finished work is durable. Because the selection is amount_paise IS NULL, a rerun naturally resumes where the last committed batch ended. Consider a short sleep between batches and a lock_timeout / statement_timeout so the backfill backs off instead of blocking the live app.
  3. High: One bad row aborts the whole migration. Decimal("1,29,999.00") and Decimal("N/A") raise InvalidOperation, so the job dies at the first legacy value, and because of the single transaction, nothing is saved. Every rerun dies at the same row: the deploy is blocked until someone hand-edits production data under pressure. Fix: Normalise the known legacy format (strip the grouping commas), and for anything still unparseable, skip and report (log the id, count it, leave it NULL for a follow-up data fix) rather than fail the run. 0043 must then gate on "zero NULLs remain" so the skipped rows are dealt with before reads switch.
  4. Medium: Silent truncation of money. int(Decimal("45.678") * 100) is 4567, quietly dropping 0.8 paise with no record. A value with more than two decimals is a data-quality signal, not something to round away in a migration. Fix: Reject values that aren't a whole number of paise (paise != paise.to_integral_value()) and report them with the other skipped rows.
  5. Medium: Race with the live app. Between the SELECT and the UPDATE, the app (already deploying the dual-write code) may have written a correct amount_paise for that row; the backfill then overwrites it with a value derived from the old column. Fix: Make the write conditional: UPDATE … WHERE id = ? AND amount_paise IS NULL, so the backfill never overwrites a value written by newer code.
  6. Low: One execute per row is 9M round-trips to Postgres; on_batch reports rows seen, which hides skipped rows; and the function returns a count that mixes success with nothing else, so the caller can't tell a clean run from a run that skipped 40,000 rows. Fix: executemany per batch (or one UPDATE … FROM (VALUES …)), and report updated and skipped separately in logs and metrics.

Tests that expose the bug:

# --- tests/test_0042.py ---
import logging

logging.disable(logging.CRITICAL)


def make_db(values):
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE payments (id INTEGER PRIMARY KEY, amount_rupees TEXT, amount_paise INTEGER)")
    conn.executemany("INSERT INTO payments (id, amount_rupees) VALUES (?, ?)", enumerate(values, start=1))
    conn.commit()
    return conn


def nulls(conn):
    return [r[0] for r in conn.execute("SELECT id FROM payments WHERE amount_paise IS NULL ORDER BY id")]


# 1. Every row is filled, with small batches so paging is exercised.
conn = make_db([f"{i}99.50" for i in range(1, 11)])
backfill_amount_paise(conn, batch_size=3)
assert nulls(conn) == [], f"rows left unfilled: {nulls(conn)}"
assert conn.execute("SELECT amount_paise FROM payments WHERE id = 10").fetchone() == (109950,)


# 2. A pod killed mid-run keeps the batches it finished; the rerun completes the job.
class PodKilled(Exception):
    pass


def kill_after_two_batches(done):
    if done >= 6:
        raise PodKilled


conn = make_db([f"{i}00.00" for i in range(1, 11)])
try:
    backfill_amount_paise(conn, batch_size=3, on_batch=kill_after_two_batches)
except PodKilled:
    conn.rollback()                      # what the server does when the connection drops
assert len(nulls(conn)) == 4, f"work lost on crash: {len(nulls(conn))} rows still NULL"
assert backfill_amount_paise(conn, batch_size=3) == 4
assert nulls(conn) == []

# 3. Legacy formats are converted; junk rows are skipped (left NULL) without aborting the run.
conn = make_db(["1299.50", "1,29,999.00", "N/A", "0.10", "", "45.678"])
backfill_amount_paise(conn, batch_size=2)
got = dict(conn.execute("SELECT id, amount_paise FROM payments"))
assert got == {1: 129950, 2: 12999900, 3: None, 4: 10, 5: None, 6: None}, got

Fixed version:

# --- migrations/0042_backfill_amount_paise.py ---
import logging
import sqlite3
from decimal import Decimal, InvalidOperation

BATCH_SIZE = 1000
logger = logging.getLogger("migrations.0042")


def to_paise(text: str) -> int | None:
    """Parse legacy rupee text; return None for values a human must look at."""
    try:
        rupees = Decimal(text.replace(",", "").strip())
    except (InvalidOperation, AttributeError):
        return None
    paise = rupees * 100
    if not paise.is_finite() or paise != paise.to_integral_value():
        return None                      # e.g. "45.678": don't guess which way to round money
    return int(paise)


def backfill_amount_paise(conn: sqlite3.Connection, batch_size: int = BATCH_SIZE, on_batch=None) -> int:
    last_id = 0
    updated = 0
    skipped = 0
    while True:
        # Keyset pagination: resume after the last id we saw. OFFSET is wrong here because
        # the rows we update drop out of the WHERE clause.
        rows = conn.execute(
            "SELECT id, amount_rupees FROM payments WHERE id > ? AND amount_paise IS NULL "
            "ORDER BY id LIMIT ?",
            (last_id, batch_size),
        ).fetchall()
        if not rows:
            break
        last_id = rows[-1][0]
        params = []
        for payment_id, rupees in rows:
            paise = to_paise(rupees)
            if paise is None:
                skipped += 1
                logger.warning("payment %s: unparseable amount_rupees %r, left NULL", payment_id, rupees)
            else:
                params.append((paise, payment_id))
        conn.executemany(
            "UPDATE payments SET amount_paise = ? WHERE id = ? AND amount_paise IS NULL", params)
        conn.commit()                    # one short transaction per batch: finished work survives a crash
        updated += len(params)
        if on_batch:
            on_batch(updated + skipped)
    logger.info("0042 done: updated=%d skipped=%d", updated, skipped)
    return updated

Follow-ups:

  1. "Why doesn't the existing test suite catch the OFFSET bug? What test would you require?" With the default batch_size=1000 and a test table of a few dozen rows, everything fits in one batch, so paging never happens. The required test uses a batch size smaller than the table (and not a divisor of it) and asserts zero NULLs remain, plus a crash-and-rerun test that asserts finished batches survive. Tests of batched code must exercise more than one batch.
  2. "Meera asks why not just UPDATE payments SET amount_paise = CAST(…) WHERE amount_paise IS NULL in one statement. When is that fine and when isn't it?" For a small table it's simplest and fine. For 9M rows on a live table it's one huge transaction (the same locking, bloat, replication-lag and all-or-nothing problems as above), and the parsing rules (Indian digit grouping, rejecting sub-paisa amounts) are awkward and error-prone in SQL. Batched, committed, resumable chunks, throttled to watch replication lag, are the standard for live backfills; a single statement is acceptable in a maintenance window or for tables that fit in seconds.
  3. "How does this backfill fit into a zero-downtime rollout of the new column?" Expand/contract: (1) add the nullable column (0041); (2) deploy code that dual-writes both columns for new rows; (3) run this backfill for old rows (conditional writes so it never overwrites dual-written values); (4) verify zero NULLs and that the columns agree on a sample; (5) switch reads; (6) enforce NOT NULL without a long lock (on Postgres: add CHECK (amount_paise IS NOT NULL) NOT VALID, then VALIDATE CONSTRAINT, then SET NOT NULL, which Postgres 12+ can do without rescanning the table) and later drop the old column. Each step is separately deployable and reversible.
  4. "How would you watch this job while it runs in production?" Progress metrics (rows updated and skipped, rows per second, current last_id against max(id) for an ETA), DB health (replication lag, lock waits, p99 latency of the app's queries on payments), and a kill switch or throttle setting the job reads between batches so on-call can slow or pause it without killing it mid-batch.

Scoring: Strong: Traces the OFFSET loop with concrete ids and shows rows are skipped, and proposes keyset pagination with the performance argument as well. Spots the single transaction and explains its effect on locks, crash recovery and the "safe to rerun" claim. Treats bad legacy data as an expected case (skip-and-report, gate the next migration) and adds the conditional UPDATE for the dual-write race. Asks for a test with more than one batch. Acceptable: Finds either the OFFSET bug or the single commit without hints, and the other with a nudge ("what happens after batch one?"). Handles the comma format but would let N/A fail the run, or needs prompting on the dual-write race. Red flag: Accepts "ordered by id so paging is stable" at face value. Suggests wrapping the whole job in one transaction "for safety". Fixes bad data by except Exception: continue around the whole batch (silently losing 1,000 rows). Has no plan for how the column becomes the source of truth.

SR-CR-05 · Dynamic micro-batching in front of a ranking model

Hard & Tricky · Code review · Senior (5–8 years) · model serving, asyncio, micro-batching, futures, failure isolation, cancellation, backpressure, FastAPI · 30 min

Context:

PR #3051 · serving/batcher.py, serving/app.py · author: Rohan · reviewers: you (owner of the search-ranking service)

The search-ranking service scores product titles with a model that is ~20x cheaper per item when called on a batch (vectorised numpy; it releases the GIL). Today every HTTP request calls the model with a batch of one. This PR adds a micro-batcher: each request enqueues its title and awaits a future; a single background worker collects up to max_batch items or waits at most max_wait seconds, runs the model in a thread, and resolves the futures.

Rohan's description:

Load test: p99 down from 180 ms to 45 ms at 2,000 RPS. I also dedupe identical titles inside a batch, because the same popular titles ("Redmi Note 13 5G") show up many times per batch. The model is a pure function, so this is safe.

Facts you know as the owner: the model raises ValueError on some malformed inputs (a few per hour); the API gateway cancels requests after 800 ms (the handler then sees CancelledError); traffic doubles during sale events.

Code under review:

# --- serving/batcher.py ---
import asyncio


class MicroBatcher:
    """Turns many concurrent predict() calls into a few batched model calls."""

    def __init__(self, model_fn, max_batch: int = 32, max_wait: float = 0.005):
        self._model_fn = model_fn          # sync: list[str] -> list[float], same order as input
        self._max_batch = max_batch
        self._max_wait = max_wait
        self._queue: asyncio.Queue = asyncio.Queue()
        self._worker: asyncio.Task | None = None

    def start(self) -> None:
        self._worker = asyncio.create_task(self._run())

    async def predict(self, text: str) -> float:
        fut = asyncio.get_running_loop().create_future()
        await self._queue.put((text, fut))
        return await fut

    async def _run(self) -> None:
        loop = asyncio.get_running_loop()
        while True:
            batch = [await self._queue.get()]
            deadline = loop.time() + self._max_wait
            while len(batch) < self._max_batch:
                remaining = deadline - loop.time()
                if remaining <= 0:
                    break
                try:
                    batch.append(await asyncio.wait_for(self._queue.get(), remaining))
                except TimeoutError:
                    break
            unique = list(dict.fromkeys(text for text, _ in batch))     # dedupe, keep order
            scores = await asyncio.to_thread(self._model_fn, unique)
            for i, (_, fut) in enumerate(batch):
                fut.set_result(scores[i])


# --- serving/app.py ---
from contextlib import asynccontextmanager

from fastapi import FastAPI
from pydantic import BaseModel


def score_titles(texts: list[str]) -> list[float]:
    return [min(len(t) / 100, 1.0) for t in texts]      # stand-in for the real model


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.batcher = MicroBatcher(score_titles)
    app.state.batcher.start()
    yield


app = FastAPI(lifespan=lifespan)


class ScoreRequest(BaseModel):
    title: str


@app.post("/score")
async def score(req: ScoreRequest) -> dict:
    return {"score": await app.state.batcher.predict(req.title)}

What a strong reviewer raises:

  1. High: Dedup misaligns scores with callers. scores lines up with unique, but the loop indexes it by position in batch. For the batch ["saree", "kurta", "saree", "pickle"] the model sees ["saree", "kurta", "pickle"]; the second "saree" caller (i=2) receives pickle's score, and the "pickle" caller (i=3) hits IndexError. So the PR silently returns the wrong ranking score to some users (no error, no log), and in the same batch crashes the worker (next issue). The load test didn't catch it because it compared latency, not answers. Fix: Map by key, not position: by_text = dict(zip(unique, scores)) and fut.set_result(by_text[text]). Validate len(scores) == len(unique) so a model that drops items fails loudly. Require a test with duplicates whose expected values differ by position.
  2. High: Any exception kills the worker, and then every request hangs forever. _run has no error handling. A ValueError from the model (a few per hour), the IndexError above, or an InvalidStateError (next issue) ends the task. Its exception is stored on a task nobody awaits, so nothing is logged until garbage collection; the futures in that batch never resolve, and every later request waits until the gateway's 800 ms timeout. The process stays up and /health still returns 200, so the orchestrator never restarts it: a full outage that looks like latency. Fix: Catch exceptions around the model call and set_exception on that batch's futures (the callers get a 500 for their own bad input), then continue the loop. Add a done-callback on the worker task that logs and fails health checks if it ever exits, and a liveness probe that checks the worker is alive and the queue is moving.
  3. High: A cancelled caller crashes the worker. When the gateway cancels a request, await fut in predict is cancelled, which cancels the future. When the batch finishes, fut.set_result(...) on a cancelled future raises asyncio.InvalidStateError, which kills the worker (previous issue). So under load, when timeouts become common, the first timeout in any batch takes the whole service down. Fix: Only resolve futures that aren't done (if not fut.done()), and drop already-cancelled requests before calling the model so the batch doesn't spend GPU/CPU on answers nobody will read.
  4. Medium: Unbounded queue, no backpressure. During a sale at 2x traffic, if the model can't keep up, the queue grows without limit; every request waits longer than the 800 ms gateway timeout, so the service does full work for requests that are already cancelled and its effective throughput drops to zero (with the cancellation bug fixed, these are at least skipped). Fix: Bounded queue with put_nowait and an explicit Overloaded → HTTP 503 so clients retry or degrade (e.g. fall back to popularity ranking). Size it from the latency budget: queue capacity ≈ throughput × acceptable wait. Export queue depth and batch size as metrics.
  5. Low: No shutdown path: on deploy, lifespan exits without stopping the worker or failing queued futures, so in-flight requests hang until the process is killed instead of failing fast. Fix: A stop() that cancels the worker and fails pending futures, called after yield in lifespan.

Tests that expose the bug:

# --- tests/test_batcher.py ---
import threading

SCORES = {"saree": 0.91, "kurta": 0.42, "pickle": 0.13, "slow": 0.5}


async def main():
    batches = []
    model_started, release = threading.Event(), threading.Event()

    def model(texts):
        batches.append(list(texts))
        if "corrupt" in texts:
            raise ValueError("tokenizer failed on input")
        if "slow" in texts:
            model_started.set()
            release.wait(2)
        return [SCORES[t] for t in texts]

    b = MicroBatcher(model, max_batch=8, max_wait=0.01)
    b.start()

    async def ask(text):
        return await asyncio.wait_for(b.predict(text), 1)

    # 1. Duplicates inside a batch: every caller gets the score for ITS title; the model sees each once.
    got = await asyncio.gather(*(ask(t) for t in ["saree", "kurta", "saree", "pickle"]))
    assert got == [0.91, 0.42, 0.91, 0.13], got
    assert batches[-1] == ["saree", "kurta", "pickle"], batches[-1]

    # 2. A model error fails that batch's callers only; the service keeps serving.
    try:
        await ask("corrupt")
    except ValueError:
        pass
    else:
        raise AssertionError("model error was not propagated to the caller")
    assert await ask("kurta") == 0.42

    # 3. A caller that goes away mid-batch must not take the worker down.
    gone = asyncio.create_task(b.predict("slow"))
    stays = asyncio.create_task(ask("kurta"))
    while not model_started.is_set():
        await asyncio.sleep(0.005)
    gone.cancel()                          # gateway timeout while the model is running
    await asyncio.sleep(0)
    release.set()
    assert await stays == 0.42
    assert await ask("pickle") == 0.13


asyncio.run(main())

Fixed version:

# --- serving/batcher.py ---
import asyncio


class Overloaded(Exception):
    """Queue is full: shed load (HTTP 503) instead of queueing past the latency budget."""


class MicroBatcher:
    """Turns many concurrent predict() calls into a few batched model calls."""

    def __init__(self, model_fn, max_batch: int = 32, max_wait: float = 0.005, max_queue: int = 1024):
        self._model_fn = model_fn          # sync: list[str] -> list[float], same order as input
        self._max_batch = max_batch
        self._max_wait = max_wait
        self._queue: asyncio.Queue = asyncio.Queue(maxsize=max_queue)
        self._worker: asyncio.Task | None = None

    def start(self) -> None:
        self._worker = asyncio.create_task(self._run())

    async def stop(self) -> None:
        if self._worker:
            self._worker.cancel()
        while not self._queue.empty():     # don't leave callers hanging at shutdown
            _, fut = self._queue.get_nowait()
            if not fut.done():
                fut.set_exception(Overloaded("shutting down"))

    async def predict(self, text: str) -> float:
        fut = asyncio.get_running_loop().create_future()
        try:
            self._queue.put_nowait((text, fut))
        except asyncio.QueueFull:
            raise Overloaded("ranking queue full") from None
        return await fut

    async def _collect(self) -> list:
        loop = asyncio.get_running_loop()
        batch = [await self._queue.get()]
        deadline = loop.time() + self._max_wait
        while len(batch) < self._max_batch:
            remaining = deadline - loop.time()
            if remaining <= 0:
                break
            try:
                batch.append(await asyncio.wait_for(self._queue.get(), remaining))
            except TimeoutError:
                break
        return batch

    async def _run(self) -> None:
        while True:
            batch = await self._collect()
            batch = [(text, fut) for text, fut in batch if not fut.done()]   # drop callers already gone
            if not batch:
                continue
            unique = list(dict.fromkeys(text for text, _ in batch))
            try:
                scores = await asyncio.to_thread(self._model_fn, unique)
                if len(scores) != len(unique):
                    raise RuntimeError(f"model returned {len(scores)} scores for {len(unique)} inputs")
            except Exception as exc:
                for _, fut in batch:
                    if not fut.done():
                        fut.set_exception(exc)
                continue
            by_text = dict(zip(unique, scores))
            for text, fut in batch:
                if not fut.done():         # the caller may have been cancelled while the model ran
                    fut.set_result(by_text[text])


# --- serving/app.py ---
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel


def score_titles(texts: list[str]) -> list[float]:
    return [min(len(t) / 100, 1.0) for t in texts]      # stand-in for the real model


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.batcher = MicroBatcher(score_titles)
    app.state.batcher.start()
    yield
    await app.state.batcher.stop()


app = FastAPI(lifespan=lifespan)


class ScoreRequest(BaseModel):
    title: str


@app.post("/score")
async def score(req: ScoreRequest) -> dict:
    try:
        return {"score": await app.state.batcher.predict(req.title)}
    except Overloaded:
        raise HTTPException(status_code=503, detail="overloaded, retry later") from None

Follow-ups:

  1. "How do you choose max_batch and max_wait? What's the trade-off?" max_wait is latency you add to the first request in every batch, so it comes out of the p99 budget; max_batch is bounded by memory and by the point where per-batch model latency stops scaling sublinearly. Measure model latency as a function of batch size, then pick the largest batch whose latency plus max_wait fits the budget at peak RPS. At low traffic a batch rarely fills, so max_wait dominates; some teams make it adaptive (don't wait when the queue is empty and the model is idle).
  2. "Why asyncio.to_thread for the model? When would you use a process pool or a separate model server instead?" to_thread keeps the event loop free to accept and enqueue requests while the model runs, and it works because numpy/BLAS release the GIL during the heavy math. If the model is pure Python (holds the GIL), threads add no parallelism and the loop stalls, so a process pool or a dedicated model server (Triton, TorchServe, a separate gRPC service) is needed. A separate server also lets you scale model replicas and the API independently and use GPUs efficiently, at the cost of a network hop and another deployable.
  3. "The model call is CPU-heavy. Why is there exactly one worker, and what would you change on a 16-core box?" One worker serialises model calls, which is right for a single GPU or a model that already uses all cores through BLAS threads. On a 16-core box with a single-threaded model, run N workers consuming the same queue (or N uvicorn worker processes, each with its own batcher), and cap BLAS threads per worker to avoid oversubscription. Measure throughput as N increases; it usually peaks below the core count.
  4. "How would you roll this out safely, given the misalignment bug got past a load test?" Shadow mode first: run the batched path alongside the existing path for a slice of traffic and compare scores per request (they must be identical for a pure model), alerting on any mismatch. Then a canary with latency, error-rate and score-distribution metrics, and a feature flag to fall back to unbatched calls. Correctness checks belong in the rollout, not just latency numbers.

Scoring: Strong: Finds the dedup misalignment by tracing a concrete batch, and recognises that wrong answers with no error are worse than a crash. Finds that an uncaught exception kills the single worker and that the service then hangs while looking healthy, and connects the gateway timeout to InvalidStateError on a cancelled future. Proposes per-batch error isolation, fut.done() checks, a bounded queue with 503s, worker liveness monitoring, and a shadow-mode rollout. Acceptable: Finds the misalignment and the missing error handling around the model call. Gets to the cancelled future issue with a hint ("what happens to fut when the client times out?"). Mentions backpressure only when asked about sale traffic. Red flag: Approves on the strength of the p99 numbers. Thinks dedup is "obviously safe because the model is pure" without checking the indexing. Proposes except Exception: pass inside the loop (callers hang instead of the worker dying). Can't explain what happens to a background task's exception in asyncio.

Theory questions

SR-TH-ASY-01 · When not to make a service async

Easy · Theory · Senior (5–8 years) · asyncio, architecture, threads vs asyncio, function colouring, trade-offs · 5 min

Question: A team proposes rewriting a Flask service (sync, gunicorn with threads) in async FastAPI "for performance". It does CRUD on PostgreSQL via SQLAlchemy and calls two internal APIs, at about 80 requests per second. How would you evaluate the proposal?

Answer:

Async buys one thing: cheap concurrency for waiting. Thousands of in-flight requests that mostly wait on sockets cost a coroutine each instead of a thread each. It doesn't make any single request faster and doesn't help CPU-bound work.

So ask what limits the service today:

  • Needed concurrency. 80 req/s × ~100 ms ≈ 8 requests in flight. A sync server with a few workers × threads handles that easily. Async pays off at hundreds or thousands of concurrent waits: websockets, long-polling, streaming, fan-out to many upstreams per request.
  • Where time goes. If p99 is dominated by slow queries or CPU (serialisation, validation), async changes nothing; fix the queries or scale processes.

Costs to weigh:

  • It's all-or-nothing down the stack ("function colouring"). Every I/O call on the request path must be async: the DB driver (asyncpg / SQLAlchemy's async engine), HTTP clients, caches, SDKs. One forgotten sync call blocks the event loop and stalls every request in that process, which is a worse failure mode than a blocked thread.
  • Migration and review cost: two styles in one codebase, async-aware testing, harder debugging (cancellation, tracebacks across tasks), and people who need to learn new failure modes.
  • Library gaps: some internal SDKs and drivers are sync-only, so you'd end up with to_thread wrappers.

A reasonable conclusion: keep the sync service and measure; adopt async where the workload is genuinely high-concurrency I/O (a notification gateway, a websocket hub, an API aggregator), possibly as a separate service. "Async for performance" without a concurrency problem is a cost without a benefit.

Follow-ups:

  1. "FastAPI supports plain def endpoints. Doesn't that make migration incremental?" Yes. def endpoints run in a threadpool, so you can move framework first and convert hot paths later. But it's only safe if nobody writes async def endpoints that call sync libraries; that mix is where event-loop blocking bugs come from. Set a lint/review rule for it.
  2. "What metric would convince you async is needed?" Thread or worker saturation while CPU is low: requests queueing for a free worker, with most of each request's time spent waiting on I/O, and memory limiting how many threads/workers you can add. Then async (or more processes) addresses the actual constraint.

Scoring: Strong: Frames async as cheap concurrency for I/O waits, does a concurrency estimate, checks where time actually goes, and weighs the whole-stack conversion, blocking-call risk and team cost. Gives a reasoned recommendation with a metric that would change it. Acceptable: Knows async helps I/O-bound high concurrency and not CPU work, but doesn't quantify or discuss migration cost. Red flag: Assumes async is faster per request, or plans to keep sync DB calls inside async def handlers.

SR-TH-ASY-02 · An OTP rate limiter that races in single-threaded asyncio

Easy & Tricky · Theory · Senior (5–8 years) · asyncio, race conditions, check-then-act, asyncio.Lock, rate limiting, Redis · 5 min

Question: An async auth service allows at most 3 OTPs per phone number per 10 minutes, tracked in an in-memory dict: check the count, await the SMS gateway, then record the send. A reviewer asks for a lock; the author says "asyncio is single-threaded, so there are no races". Who's right?

Answer:

Trap: "Single-threaded means no races." In asyncio, a coroutine runs uninterrupted only between awaits. Any await can hand control to another task. Here the check (count < 3) and the act (record the send) are separated by await send_sms(...), so five concurrent requests all pass the check before any of them records a send: five OTPs go out, as below. It's the classic check-then-act race; asyncio only makes the switch points explicit.

Fixes, in order of preference:

  • No await between check and act: reserve first (append a pending entry, then await the send, and remove the entry if the send fails). The decision and the state update happen atomically from the loop's point of view.
  • asyncio.Lock per key around the whole sequence, as below. Bound the lock dict (evict idle keys) or it grows with every phone number, and hold locks only as long as needed.
  • But in production this state can't be in-process at all. With 4 workers × 6 pods, each process enforces its own "3", i.e. up to 72 OTPs. Use Redis: INCR otp:{phone} with EXPIRE, or a small Lua script for a sliding window, so the check-and-increment is atomic across the fleet.

Reviewer guidance: in async code, read every await as "anything can change here", and look for decisions made before an await and acted on after it.

import asyncio

sent: dict[str, list[int]] = {}
LIMIT = 3


async def send_sms(phone):
    await asyncio.sleep(0)                     # the SMS gateway call


async def request_otp(phone, n):
    if len(sent.get(phone, [])) < LIMIT:       # check
        await send_sms(phone)                  # other requests run here
        sent.setdefault(phone, []).append(n)   # act on a stale check
        return "sent"
    return "rate-limited"


async def main():
    results = await asyncio.gather(*(request_otp("98450xxxxx", n) for n in range(5)))
    print(results, len(sent["98450xxxxx"]))

    sent.clear()
    locks: dict[str, asyncio.Lock] = {}

    async def request_otp_locked(phone, n):
        async with locks.setdefault(phone, asyncio.Lock()):   # serialise per phone
            return await request_otp(phone, n)

    results = await asyncio.gather(*(request_otp_locked("98450xxxxx", n) for n in range(5)))
    print(results, len(sent["98450xxxxx"]))


asyncio.run(main())

Output:

['sent', 'sent', 'sent', 'sent', 'sent'] 5
['sent', 'sent', 'sent', 'rate-limited', 'rate-limited'] 3

Follow-ups:

  1. "With "reserve first", what happens if the SMS gateway call is cancelled by a request timeout?" CancelledError is raised at the await; the rollback must run in except BaseException: or finally (and re-raise) so a cancelled send releases its reservation. Whether a cancelled send counts is a product decision: the SMS may already have been delivered.
  2. "Is locks.setdefault(phone, asyncio.Lock()) itself race-free?" Yes in asyncio: it has no await, so it runs atomically with respect to other tasks. The same line in threaded code would also be fine under the GIL for a dict, but the general check-then-act rule applies to anything spanning an await or a thread switch.

Scoring: Strong: Explains that tasks interleave at await points, predicts 5 sends, fixes it with reserve-before-await or a per-key lock, and points out that per-process state is wrong across workers, proposing an atomic Redis counter. Acceptable: Agrees with the reviewer and adds an asyncio.Lock, but doesn't address multiple workers. Red flag: Agrees there's no race because asyncio is single-threaded, or adds a threading.Lock inside the coroutine.

SR-TH-ASY-03 · def vs async def endpoints in FastAPI, and the 40-thread limit

Medium · Theory · Senior (5–8 years) · FastAPI, asyncio, threadpool, AnyIO, blocking I/O, capacity · 6 min

Question: In a FastAPI service, some endpoints are async def and some are plain def. Explain how each kind runs, how you decide which to use, and what happens when 200 concurrent requests hit a def endpoint whose DB call takes 2 seconds.

Answer:

How they run:

  • async def endpoints run directly on the event loop. They must only do non-blocking work and await async libraries. Any blocking call (sync DB driver, requests, time.sleep, heavy CPU) freezes the loop and every other request in that worker.
  • def endpoints (and sync dependencies) run in a threadpool via AnyIO's to_thread, so blocking is fine. That pool has a default limit of 40 threads per process, shared by all sync endpoints, sync dependencies and other run_in_threadpool users.

Rule of thumb: use async def only when everything inside is async (asyncpg, httpx.AsyncClient, async Redis); use def when the code uses sync libraries (sync SQLAlchemy sessions, boto3, most SDKs). The dangerous combination is async def calling a sync library.

The 200-request scenario: 40 requests occupy the 40 threads for 2 s each; the other 160 wait for a thread token, so latencies stack up in waves (2 s, 4 s, ...), up to 10 s. Health checks that are also sync def endpoints queue behind them and time out, so the orchestrator may restart a pod that is just slow. CPU stays low, which is a useful diagnostic signal.

What to do about it:

  • Fix the 2-second query first; no concurrency model hides it.
  • Size deliberately: raise the limiter (anyio.to_thread.current_default_thread_limiter().total_tokens = N) only alongside a matching DB pool size, and remember total DB connections = workers × pods × pool size.
  • Keep health/readiness endpoints async def and dependency-free so they don't queue behind work.
  • Or move that path to async drivers so waiting doesn't need threads.

Follow-ups:

  1. "A def endpoint returns a StreamingResponse over a sync generator that reads a big file. Any concern?" Starlette iterates a sync generator in the threadpool chunk by chunk, so a slow client keeps taking thread tokens throughout the download. Many concurrent downloads can starve the pool. Use an async generator with async file I/O, or offload large downloads to object storage with pre-signed URLs.
  2. "How would you detect in production that an async def endpoint is blocking the loop?" Event-loop lag metrics (a task measuring how late sleep wakes up), asyncio debug mode's slow-callback warnings in staging, and correlated latency spikes across unrelated endpoints in the same worker. py-spy dump during a spike shows the blocking call on the main thread.

Scoring: Strong: Explains loop vs threadpool execution, the shared 40-token limit, the queueing waves in the scenario and the health-check side effect, and ties pool sizing to DB pool limits. Gives a clear rule for choosing def vs async def. Acceptable: Knows sync endpoints run in a threadpool and async ones on the loop, but not the limit or its consequences. Red flag: Thinks async def makes a sync DB call non-blocking, or makes every endpoint async def by default.

SR-TH-ASY-04 · Feeding an asyncio.Queue from a Kafka consumer thread

Medium & Tricky · Theory · Senior (5–8 years) · asyncio, thread safety, call_soon_threadsafe, run_coroutine_threadsafe, threads and event loops · 6 min

Question: An async service uses a sync Kafka client running in a background thread. The thread calls inbox.put_nowait(msg) on an asyncio.Queue that a coroutine is awaiting. Messages sometimes sit unprocessed for seconds, and occasionally the consumer seems stuck until another request arrives. What's wrong, and how should the thread hand messages to the loop?

Answer:

Trap: "asyncio.Queue is a queue, so it's thread-safe like queue.Queue." Almost nothing in asyncio is thread-safe. asyncio.Queue, Lock, Event, futures and the loop's scheduling methods assume they're used from the loop's own thread. When the Kafka thread calls put_nowait, it appends to the deque and resolves the waiting getter's future from the wrong thread. That schedules the getter's wake-up on the loop's ready queue, but nothing wakes the loop from its select()/epoll wait, so the consumer resumes only when some other I/O event happens to wake the loop. Hence the multi-second delays and "stuck until another request arrives". Unsynchronised access from two threads can also corrupt the internal state.

Correct hand-offs from a foreign thread:

  • loop.call_soon_threadsafe(inbox.put_nowait, msg): schedules the call on the loop's thread and writes to the loop's self-pipe to wake it. Capture loop = asyncio.get_running_loop() in the loop thread and pass it to the worker thread.
  • asyncio.run_coroutine_threadsafe(coro, loop) when the thread needs a result back: it returns a concurrent.futures.Future the thread can block on with a timeout.
  • The other direction (loop → blocking code) is await asyncio.to_thread(fn) or loop.run_in_executor.

Also add backpressure: call_soon_threadsafe on an unbounded queue lets a fast producer outrun the consumer. Use a bounded asyncio.Queue and have the thread wait on run_coroutine_threadsafe(inbox.put(msg), loop).result() so it blocks when the loop falls behind. Running the loop with debug=True in tests makes many wrong-thread calls raise RuntimeError instead of misbehaving silently. Better still, use an async Kafka client.

import asyncio
import threading


async def main():
    loop = asyncio.get_running_loop()
    inbox: asyncio.Queue[str] = asyncio.Queue()

    def kafka_consumer_thread():
        # Wrong: inbox.put_nowait(msg) from this thread is not thread-safe and
        # may not wake the waiting consumer. Hand the call to the loop instead:
        for msg in ["order-1", "order-2"]:
            loop.call_soon_threadsafe(inbox.put_nowait, msg)

        async def lookup(sku):
            return f"price({sku})=499"
        fut = asyncio.run_coroutine_threadsafe(lookup("SKU-7"), loop)
        print("thread got", fut.result(timeout=5))

    t = threading.Thread(target=kafka_consumer_thread)
    t.start()
    print("loop got", await inbox.get())
    print("loop got", await inbox.get())
    await asyncio.to_thread(t.join)


asyncio.run(main())

Output:

loop got order-1
loop got order-2
thread got price(SKU-7)=499

Follow-ups:

  1. "Why not just use a queue.Queue and have the coroutine call q.get()?" queue.Queue.get() blocks the thread, which is the event loop's thread, so the whole service stops while waiting. You could await asyncio.to_thread(q.get), but that ties up an executor thread per waiting consumer. The call_soon_threadsafe bridge (or a library like janus with sync and async faces) keeps the loop free.
  2. "The thread calls fut.result() with no timeout and the loop is shutting down. What happens?" If the loop stops before running the coroutine, the future never completes and the thread blocks forever, preventing a clean exit. Always pass a timeout, and on shutdown signal the thread to stop before closing the loop.

Scoring: Strong: States asyncio primitives aren't thread-safe, explains the missing loop wake-up and data race, uses call_soon_threadsafe/run_coroutine_threadsafe correctly with a captured loop, and adds backpressure plus debug-mode detection. Acceptable: Knows to use call_soon_threadsafe but can't explain the symptom of delayed processing. Red flag: Wraps put_nowait in a threading.Lock, or calls asyncio.run() inside the thread to "use the queue".

SR-TH-ASY-05 · Finding what blocks the event loop in production

Hard · Theory · Senior (5–8 years) · asyncio, event loop lag, observability, debug mode, py-spy, performance · 8 min

Question: An async API gateway shows periodic p99 spikes to 2-3 s across all endpoints at once, while CPU averages 30%. You suspect something blocks the event loop. How do you prove it and find the culprit in production, and what are the usual suspects?

Answer:

Simultaneous latency spikes across unrelated endpoints in the same worker are the signature of a blocked loop: while one callback runs without yielding, no other task makes progress, timers fire late and even timeouts are delayed.

Prove it with a lag metric. Run a background task that sleeps for a fixed interval (say 250 ms) and records how late it woke up (loop.time() after minus expected). Export it as a histogram per worker. A healthy loop shows lag in single-digit milliseconds; spikes to seconds that line up with the latency spikes are your proof. This is cheap enough to run always.

Find the culprit:

  • asyncio debug mode (PYTHONASYNCIODEBUG=1 or asyncio.run(main(), debug=True)) logs every callback or task step slower than loop.slow_callback_duration (100 ms by default) with the task's coroutine. It has real overhead, so use it in staging or on one canary instance with production-like traffic.
  • py-spy dump during a spike shows the main thread's stack at the moment it's blocked; py-spy record over a few minutes gives a flame graph where blocking calls show up on the loop thread.
  • Tracing spans that include loop lag at span start help find which request kind precedes the stalls.

Usual suspects: a sync client hidden in a helper or SDK (requests, boto3, a sync Redis call, a DB driver); CPU work on the loop (serialising a large JSON response, pydantic validation of big payloads, regexes on large strings, password hashing with bcrypt, gzip); synchronous logging handlers writing to a network or a slow disk; file I/O; big sorted() or pandas operations in a handler.

Fixes: async libraries for I/O; asyncio.to_thread for blocking calls that release the GIL; a process pool or a separate service for CPU-heavy work; chunk large CPU work with await asyncio.sleep(0) between pieces if it must stay inline; QueueHandler/QueueListener for logging. Then keep the lag metric and an alert on it so regressions are caught by monitoring, not by users.

Follow-ups:

  1. "Why might moving bcrypt hashing to asyncio.to_thread not fully fix the latency?" It frees the loop only if the hashing releases the GIL; many bcrypt implementations do, but pure-Python CPU work in a thread still competes for the GIL with the loop thread, so the loop gets slower in proportion. CPU-heavy work belongs in a process pool or a separate service, and the default thread pool's size limits concurrency too.
  2. "Debug mode reports "Executing took 0.9 seconds". The handler is all awaits. How is that possible?" The 0.9 s is time between two await points inside it, i.e. synchronous code that runs in one step: a loop over results, a big json.dumps, or a call into a library function that turns out to be sync. Every await that returns without suspending also counts as part of the same step.

Scoring: Strong: Explains the blocked-loop signature, proposes an always-on loop-lag metric, uses debug mode safely and py-spy for attribution, lists realistic culprits including CPU and logging, and matches fixes to cause (threads vs processes vs async libraries). Acceptable: Knows a sync call can block the loop and suggests debug mode or profiling, without a production-safe metric. Red flag: Adds more workers or bigger instances without finding the blocking call, or blames the network.

SR-TH-ASY-06 · An await that never yields starves the heartbeat task

Hard & Tricky · Theory · Senior (5–8 years) · asyncio, scheduling, starvation, asyncio.Queue, heartbeats, fairness · 7 min

Question: After a slow deploy, an async consumer has 10,000 messages backed up in an asyncio.Queue. While it drains them, its heartbeat task stops running, the broker declares the consumer dead and triggers a rebalance. The drain loop does await queue.get() on every iteration. How can a loop full of awaits starve other tasks?

Answer:

Trap: "Every await gives control back to the event loop." An await only suspends the task if the thing being awaited isn't ready. await some_coroutine() simply runs the coroutine inline; the task yields to the loop only when, somewhere down the chain, it awaits a future that isn't done yet. Queue.get() on a non-empty queue returns immediately without suspending. So while the backlog lasts, the drain loop runs 10,000 iterations in one task step: no other task, timer, or I/O callback runs until it's finished. The heartbeat (and health checks, timeouts, other consumers) is starved, exactly as in the first output line below.

The same happens with an uncontended asyncio.Lock, a StreamReader that already has buffered data, async for over an async generator that never truly waits, or a cache hit wrapped in a coroutine.

Fixes:

  • Yield explicitly in long loops: await asyncio.sleep(0) every N items (second output below). It's the idiomatic "let others run" in asyncio.
  • Bound the work per step: process in batches and yield between batches; keep CPU work per item small, or move it to a process pool.
  • Don't put liveness on the same loop as heavy work when it matters: some clients run heartbeats in a separate thread for exactly this reason. Configure session/poll timeouts with backlog processing time in mind.
  • Measure event-loop lag; this kind of starvation shows up as lag even though no single call is "blocking".

import asyncio

log = []


async def heartbeat():
    for _ in range(3):
        log.append("heartbeat")
        await asyncio.sleep(0)


async def drain(queue, yield_every=None):
    n = 0
    while not queue.empty():
        await queue.get()                  # an item is ready: returns WITHOUT suspending
        n += 1
        if yield_every and n % yield_every == 0:
            await asyncio.sleep(0)         # explicitly give other tasks a turn
    log.append(f"drained {n}")


async def run(yield_every):
    log.clear()
    q = asyncio.Queue()
    for i in range(10_000):
        q.put_nowait(i)                    # a backlog built up during a slow deploy
    hb = asyncio.create_task(heartbeat())
    await drain(q, yield_every)
    await hb
    print(log)


asyncio.run(run(yield_every=None))
asyncio.run(run(yield_every=2_500))

Output:

['drained 10000', 'heartbeat', 'heartbeat', 'heartbeat']
['heartbeat', 'heartbeat', 'heartbeat', 'drained 10000']

Follow-ups:

  1. "Is await asyncio.sleep(0) after every single item a good idea?" It's correct but costs a trip through the event loop per item, which can be a significant overhead for tiny items. Yield every N items or every few milliseconds of work (check loop.time()), trading a little latency for others against throughput.
  2. "Why didn't the heartbeat at least run once before the drain started?" create_task only schedules the task; it starts running at the next loop iteration. The current task went straight into drain and never suspended, so the heartbeat's first step waited until run reached await hb.

Scoring: Strong: Explains that await suspends only on a not-yet-done future, predicts the first log order, lists other non-yielding awaits, and fixes it with periodic sleep(0), batching and separating liveness, plus loop-lag monitoring. Acceptable: Fixes it with await asyncio.sleep(0) but believes the queue is somehow "blocking", without the suspension model. Red flag: Insists every await yields and blames the broker, or moves the drain into to_thread with the same asyncio.Queue.

SR-TH-BE-01 · Rate limiting a public API across many instances

Easy · Theory · Senior (5–8 years) · rate limiting, token bucket, Redis, API design, 429, abuse protection · 6 min

Question: Your public order-tracking API runs on 12 instances behind a load balancer, and one partner's buggy integration is hammering it. You're asked to add rate limiting. Which algorithm, where does the state live, and what does a client see?

Answer:

Decide what you're protecting and per what key first: per API key or tenant (fairness between partners), per user or IP for unauthenticated endpoints (abuse), and sometimes per expensive endpoint (a search costs more than a status lookup). Limits live in configuration per plan, not in code.

Algorithms:

Algorithm Behaviour Trade-off
Fixed window (count per minute) Simple counter with expiry Allows 2× bursts at window edges (end of one minute + start of the next)
Sliding window log Exact, stores each request time Memory per client grows with the limit
Sliding window counter Weighted blend of current and previous window Good approximation, cheap
Token bucket Refills at rate r, holds up to b tokens; each request takes one Allows controlled bursts up to b, smooth long-term rate; my default

Where state lives: with 12 instances, per-process counters give each client 12× the limit and reset on deploys, so use a shared store, usually Redis. The check-and-decrement must be atomic: a Lua script (or a single INCR + EXPIRE for fixed windows) so two instances can't both take the last token. Keys carry a TTL so idle clients cost nothing. Decide the failure mode: if Redis is down, fail open (allow, with an alert) for a business API, fail closed for something like an OTP endpoint, where abuse is costly.

Often the right layering is: coarse per-IP limits at the edge (API gateway, CDN, ingress) to absorb floods cheaply, and precise per-tenant limits in the application where the identity is known.

What the client sees: 429 Too Many Requests with Retry-After, and ideally rate-limit headers (limit, remaining, reset) on every response, so well-behaved clients can pace themselves. Document the limits, and log and meter rejections per tenant so you can tell a buggy partner from an attack, and talk to them.

Follow-ups:

  1. "Why is rate limiting not enough to protect the service from overload?" Limits are per client; many legitimate clients together can still exceed capacity, and a few expensive requests can saturate a dependency. You also need load shedding based on the server's own state (queue depth, concurrency limits, latency), timeouts, and bulkheads, so the service degrades instead of collapsing.
  2. "A Redis round trip on every request adds latency. Can you avoid it?" Keep a small local token allowance per instance that's refilled from Redis in batches (accepting slight over-admission), or do coarse limiting at the gateway and exact limiting only on expensive endpoints. Measure first: a pipelined Redis call in the same zone is usually well under a millisecond.

Scoring: Strong: Chooses keys by purpose; compares algorithms and picks token bucket with reasons; puts state in Redis with atomic operations and TTLs; chooses fail-open vs fail-closed deliberately; layers edge and app limits; returns 429 with Retry-After and headers. Acceptable: Describes a fixed or sliding window in Redis returning 429, without atomicity, failure mode or layering. Red flag: Keeps counters in a per-process dict on 12 instances, or blocks the client's IP permanently as the fix.

SR-TH-BE-02 · "We set a 30-second timeout" and the request still hangs for minutes

Easy & Tricky · Theory · Senior (5–8 years) · timeouts, httpx, retries, deadlines, retry storms, resilience · 6 min

Question: A checkout service calls a KYC provider with httpx.Client(timeout=30). During a provider incident, checkout requests hung for several minutes, every worker was busy, and the whole site went down. The author says "the timeout is 30 seconds, so a call can't take longer than that". What's wrong with that belief, and how should the service be configured?

Answer:

Trap: "timeout=30 caps the call at 30 seconds."

In httpx, timeout=30 sets four separate limits to 30 s each: connect, read, write and pool (waiting for a free connection from the pool). None of them is a total deadline. The read timeout is the maximum gap between received chunks, so a provider that trickles a byte every 20 seconds keeps the call alive indefinitely. A call can spend up to 30 s waiting for a pooled connection, 30 s connecting, then a long time reading.

Then retries multiply it. If the browser SDK, the API gateway and the service's HTTP client each make 3 attempts, a single user action can become 27 attempts against a provider that's already failing, each allowed 30 s per phase. That's both the multi-minute hang and a retry storm that keeps the provider down.

What I'd change:

  • Per-phase timeouts from real latency data: connect around 1 s (a TCP connect within a region takes milliseconds), read just above the provider's p99.9, and pool timeout short, so you fail fast when saturated.
  • A total deadline around the whole operation, including retries: asyncio.timeout() (or anyio.fail_after) in async code, or a deadline that's checked before each attempt and passed down as the remaining budget (e.g. in a header), so downstream services don't keep working on a request the user already abandoned.
  • Retry at one layer only, only for idempotent operations or with an idempotency key, with exponential backoff and jitter, and a retry budget (e.g. retries at most 10% of traffic).
  • Circuit breaker around the provider so that after repeated failures calls fail immediately, plus a fallback (queue the KYC check, let the user continue to a "pending verification" state).
  • Bulkhead: a separate, bounded connection pool and concurrency limit for this dependency, so one slow provider can't consume every worker.

import httpx

t = httpx.Timeout(30.0)
print(t.connect, t.read, t.write, t.pool)

# What a "30 s timeout" can mean end to end with retries at three layers:
browser_sdk, api_gateway, service_client = 3, 3, 3   # attempts per layer
attempts = browser_sdk * api_gateway * service_client
print(f"worst case: {attempts} attempts, each allowed 30 s per read")

# A deadline-style budget instead: one connect limit, a shorter read limit.
budget = httpx.Timeout(5.0, connect=1.0)
print(budget.connect, budget.read, budget.write, budget.pool)

Output:

30.0 30.0 30.0 30.0
worst case: 27 attempts, each allowed 30 s per read
1.0 5.0 5.0 5.0

Follow-ups:

  1. "What's the default timeout in requests and in httpx if you don't pass one?" requests has no default timeout at all: a call can wait forever, which is why linters flag missing timeout= arguments. httpx defaults to 5 seconds for each phase. Either way, set timeouts explicitly per dependency, based on its measured latency.
  2. "Which layer should own retries?" Usually the one closest to the failing dependency that knows whether the operation is safe to repeat, typically the service's client for that dependency, with outer layers not retrying (or retrying only on explicit signals like 503 with Retry-After). The important rule is that layers don't each retry independently.

Scoring: Strong: Knows the timeout is per phase and read is per chunk, with no total deadline; quantifies retry multiplication; prescribes measured per-phase timeouts, an overall deadline propagated downstream, single-layer retries with backoff and jitter, circuit breaker and bulkhead. Acceptable: Suspects retries made it worse and suggests smaller timeouts and a circuit breaker, without understanding httpx's timeout semantics. Red flag: Raises the timeout or the number of workers, or adds another retry layer.

SR-TH-BE-03 · Sizing database connection pools across workers and pods

Medium · Theory · Senior (5–8 years) · connection pooling, SQLAlchemy, PostgreSQL, PgBouncer, autoscaling, capacity planning · 7 min

Question: After autoscaling kicked in during a sale, the API started failing with FATAL: sorry, too many clients already from Postgres, even though database CPU was at 40%. Each pod runs 4 Gunicorn workers with SQLAlchemy's default pool settings. Explain what happened and how you'd size pools.

Answer:

Connection pools are per process. SQLAlchemy's default QueuePool allows pool_size=5 plus max_overflow=10: up to 15 connections per worker process. So the ceiling is

pods × workers per pod × 15, e.g. 20 pods × 4 × 15 = 1,200 connections,

against a Postgres max_connections that is often 100–500. Autoscaling multiplies the pods, and so the connections, exactly when load is highest. CPU being at 40% is irrelevant; the connection limit is a separate hard limit, and each Postgres connection is a backend process with its own memory.

How I'd size it:

  1. Start from the database's budget: max_connections minus headroom for admin, migrations, replication, and other services. Say 300 usable.
  2. Divide by the maximum number of processes at the autoscaler's upper bound: 300 / (30 pods × 4 workers) ≈ 2–3 connections per process. That forces a design question rather than a config tweak.
  3. Small pools are usually enough, and faster: Postgres throughput peaks at a modest number of active connections (on the order of a few per CPU core); beyond that, connections queue on locks and CPU. A request should hold a connection for milliseconds, so a pool of 2–5 per process serves a lot of traffic, as long as code doesn't hold a connection during external calls (an HTTP call to a payments provider inside an open transaction is a classic pool-exhauster).
  4. Put a pooler in front when the process count is large: PgBouncer in transaction pooling mode lets thousands of client connections share a few dozen server connections. It breaks session state: session-level SET, advisory locks, LISTEN/NOTIFY, temporary tables, and server-side prepared statements (asyncpg's statement cache must be disabled or PgBouncer configured for prepared statements).
  5. Fail fast and observe: a short pool_timeout so requests fail quickly instead of piling up, pool_pre_ping (or pool_recycle) for connections killed by idle timeouts, and metrics on checked-out connections and wait time per pool. Cap autoscaling where the database, not the app, is the bottleneck.

Follow-ups:

  1. "With an async SQLAlchemy engine in each Uvicorn worker, does the per-process math change?" No: there's still one pool per process (per event loop). Async lets one process run many concurrent requests, which makes it easier to exhaust a small pool, so the pool timeout and per-request connection hold time matter even more. Never share an engine across event loops or forked processes; create it after fork, in the lifespan handler.
  2. "Why is pool_size larger than needed a problem if connections sit idle?" Idle connections still use memory on the database server and count against max_connections; under a traffic spike every process opens its maximum at once, causing a connection storm exactly when the database is busiest. Large pools also hide slow queries and connection leaks until they cause an outage.

Scoring: Strong: Computes the per-process multiplication with autoscaling; sizes from the database budget downward; knows small pools are often faster and that holding connections during external I/O exhausts them; uses PgBouncer and knows what transaction pooling breaks; fails fast with metrics. Acceptable: Recognises too many connections from many processes and suggests lowering pool size or adding PgBouncer, without the budget calculation or its caveats. Red flag: Raises max_connections to several thousand, or increases pool sizes because requests are waiting.

SR-TH-BE-04 · Deploys drop requests even though Uvicorn shuts down gracefully

Medium & Tricky · Theory · Senior (5–8 years) · graceful shutdown, Kubernetes, SIGTERM, Uvicorn, readiness, background tasks · 7 min

Question: Every deploy of a FastAPI service on Kubernetes causes a burst of 502s and a few lost "send receipt email" background tasks. The team says "Uvicorn handles SIGTERM gracefully, so it can't be the shutdown". Where do the errors come from, and what do you change?

Answer:

Trap: "The app server drains in-flight requests on SIGTERM, so shutdown is safe."

Uvicorn does stop accepting new connections and waits for in-flight requests when it gets SIGTERM. The failures come from what happens around that:

  1. Endpoint removal races the signal. When a pod is terminated, Kubernetes sends SIGTERM (after any preStop hook) and, in parallel, removes the pod from the Service endpoints. It takes seconds for kube-proxy, the ingress and any cloud load balancer to stop sending traffic. Meanwhile the app has already closed its listening socket, so new requests get connection refused, which the proxy reports as 502.
  2. Keep-alive connections: clients and proxies reuse idle connections that the server is now closing, and a request sent on one just as it closes fails.
  3. Grace period: after terminationGracePeriodSeconds (30 s by default) the container gets SIGKILL. Long requests, streaming responses and WebSockets are cut.
  4. BackgroundTasks are in-process: they run after the response is sent, in the same process. Anything still running or not yet started at SIGKILL is simply lost; nothing records that the receipt was never sent.
  5. Signal delivery: if the container starts with a shell script (sh -c "uvicorn ...") and the shell is PID 1, SIGTERM may never reach Uvicorn, so it never drains and is killed after 30 s.

Changes:

  • A preStop hook that sleeps (e.g. 10–15 s) so the pod keeps serving while it's removed from load balancers, then receives SIGTERM. Optionally fail the readiness probe as soon as shutdown starts.
  • Budget the grace period: preStop sleep + longest expected request + lifespan shutdown work (closing pools, flushing telemetry) < terminationGracePeriodSeconds; set Uvicorn's --timeout-graceful-shutdown within it.
  • Move work that must happen to a durable queue (Celery, RQ, a jobs table, or an outbox) instead of BackgroundTasks; keep BackgroundTasks for best-effort work.
  • Exec form in the Dockerfile (CMD ["uvicorn", ...]) or a small init like tini, so signals reach the server.
  • Rolling update settings (maxUnavailable: 0, a sensible maxSurge) and a PodDisruptionBudget, so capacity doesn't dip during the deploy.

Follow-ups:

  1. "How do you verify the fix rather than waiting for the next deploy?" Run a steady load test (e.g. a few hundred requests per second with keep-alive) against a staging deployment and trigger a rolling restart; count non-2xx responses and dropped jobs. Make it a release check, because regressions here are invisible until a busy-hour deploy.
  2. "What should a queue consumer do on SIGTERM?" Stop fetching new messages, finish (or abandon cleanly) the current one, acknowledge only after the work's side effects are committed, and exit within the grace period. Unacknowledged messages are redelivered to another worker, which is why handlers must be idempotent.

Scoring: Strong: Explains the endpoint-propagation race, keep-alive, the grace-period SIGKILL, in-process background tasks and PID 1 signal handling; fixes with preStop delay, a grace budget, a durable queue and exec-form startup. Acceptable: Identifies the load balancer still sending traffic after SIGTERM and adds a preStop sleep, but misses lost background work or signal delivery. Red flag: Blames Uvicorn and proposes retrying 502s at the client, or increases the grace period to several minutes without understanding the race.

SR-TH-BE-05 · Saving an order and publishing its event without losing either

Hard · Theory · Senior (5–8 years) · transactional outbox, dual writes, Kafka, event-driven architecture, idempotent consumers, CDC · 8 min

Question: The order service does session.commit() and then producer.send("order-placed", event). Occasionally the warehouse never hears about an order; once, it shipped an order that had been rolled back. Explain the failure and design a fix.

Answer:

This is the dual-write problem: two systems (Postgres and Kafka) with no shared transaction. Whatever order you write them in, a crash or error between the two leaves them inconsistent:

  • Commit, then publish: the process dies, the broker is unreachable, or the send fails after commit, so the order exists but no event is ever sent. Fire-and-forget producers (an un-awaited future) hide this completely.
  • Publish, then commit: the commit fails (constraint violation, deadlock, timeout), so the event describes an order that doesn't exist. That's how a rolled-back order got shipped.

Retrying doesn't solve it, and Kafka transactions don't help because they don't include the database.

Transactional outbox:

  1. In the same database transaction as the order, insert the event into an outbox table (event ID, aggregate ID, type, payload, created time). Either both commit or neither does.
  2. A relay publishes outbox rows to Kafka and marks them sent. Either a poller (SELECT ... ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED, so several relays can run safely), or log-based CDC (Debezium reading the WAL), which avoids polling and preserves commit order.
  3. The relay can publish and crash before marking the row sent, so delivery is at least once: every event carries a unique ID, and consumers are idempotent (an inbox/processed-events table keyed by event ID, or naturally idempotent upserts).
  4. Ordering: use the order ID as the Kafka key so events for one order land in one partition in order, and have the relay publish per aggregate in outbox order.
  5. Operate it: alert on outbox lag (oldest unsent row age), purge or archive sent rows, and version the event schema as a data contract.

Alternatives: CDC directly on the business tables (no outbox, but consumers become coupled to your table schema), or "listen to yourself" (publish only, and have the service consume its own event to write the DB), which makes the write path asynchronous for the user. Distributed two-phase commit is rarely available or worth it.

Follow-ups:

  1. "Why FOR UPDATE SKIP LOCKED in the poller?" It lets several relay instances poll concurrently without blocking on, or double-processing, each other's rows: each picks up rows that aren't currently locked. Without it you either run a single relay (a single point of failure) or get duplicate publishes from concurrent pollers.
  2. "The consumer is idempotent by event ID. Is that enough if two events for the same order arrive out of order?" No. Idempotency handles duplicates, not ordering. Keep per-key ordering (same partition key and a relay that preserves order), and make consumers order-aware where it matters: store a per-order version or sequence number and ignore events older than what's already applied.

Scoring: Strong: Explains both dual-write orders and why retries or Kafka transactions don't fix it; designs an outbox in the same transaction with a relay (SKIP LOCKED poller or CDC), at-least-once delivery with idempotent consumers, per-key ordering and lag monitoring; knows the alternatives. Acceptable: Proposes an outbox table and a background publisher, but misses duplicates, ordering or operations. Red flag: Wraps both calls in a try/except with a retry, or publishes before committing "so the event is never lost".

SR-TH-BE-06 · An "additive" API change that breaks clients

Hard & Tricky · Theory · Senior (5–8 years) · API design, backward compatibility, versioning, pydantic, enums, tolerant reader · 7 min

Question: Your payments API adds a new status value, partially_refunded, and a new optional response field, upi_ref. The PR description says "additive change, non-breaking, no version bump needed". Within an hour two partner integrations are failing. How, and what policy would you set?

Answer:

Trap: "Adding fields and enum values is always backward compatible."

Compatibility is defined by what clients do with the response, not by the schema diff. Two very common client behaviours break here:

  • Closed enums: a client SDK generated from last quarter's spec models status as a fixed enum, so the new value fails validation (or falls through an exhaustive match/switch into an error path, or is silently mapped to the wrong bucket).
  • Strict objects: clients that validate responses with "no unknown fields" (Pydantic extra="forbid", JSON Schema additionalProperties: false, some strict deserialisers in typed languages) reject the extra key.

The snippet reproduces both with a Pydantic client model: the old payload validates, the new enum value and the new field each fail. (Pydantic's default is extra="ignore"; the client opted into strictness.)

More generally, by Hyrum's law every observable behaviour gets depended on: default sort order, page size, a field that was never null, error message text, timing.

Policy I'd set for the API:

  1. Publish a compatibility contract for clients: ignore unknown fields; treat enums as open and handle unknown values with a fallback. Make our own SDKs do this (enum fields typed as str with known constants, or an UNKNOWN member via a validator), so the official clients can't be the ones that break.
  2. Classify changes: new optional fields are compatible under that contract; new enum values are announced ahead with a date (or gated behind an opt-in/version) because they change client logic; removing/renaming fields, changing types or meaning, and making fields required are breaking.
  3. Breaking changes get a new version (path /v2 or a dated version header, Stripe-style), with old versions supported for a published period, Deprecation/Sunset headers, and per-client usage telemetry to know who still calls what.
  4. Automated checks in CI: diff the OpenAPI schema against the released one and flag breaking changes; contract tests with the largest partners' expectations.

from enum import Enum
from pydantic import BaseModel, ConfigDict, ValidationError

class PaymentStatus(str, Enum):        # the client SDK's copy, generated last quarter
    PENDING = "pending"
    SUCCEEDED = "succeeded"
    FAILED = "failed"

class Payment(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str
    status: PaymentStatus

old = {"id": "pay_1", "status": "succeeded"}
new = {"id": "pay_2", "status": "partially_refunded"}                  # new enum value
added = {"id": "pay_3", "status": "pending", "upi_ref": "4321"}        # new optional field

for payload in (old, new, added):
    try:
        print("ok  ", Payment.model_validate(payload).status.value)
    except ValidationError as e:
        err = e.errors()[0]
        print("FAIL", err["type"], err["loc"])

Output:

ok   succeeded
FAIL enum ('status',)
FAIL extra_forbidden ('upi_ref',)

Follow-ups:

  1. "A partner refuses to change their strict client. What can you do without blocking your roadmap?" Pin them to a version that never shows the new value and field (per-client API version, as Stripe does with dated versions), and translate at the edge: map partially_refunded to the nearest old value for that version, and strip new fields. That's a response-transformation layer per version, which is why versioning needs infrastructure, not just a URL prefix.
  2. "Should your own server models use extra="forbid" for request bodies?" For requests, often yes: rejecting unknown fields catches client typos (ammount) that would otherwise be silently ignored. The asymmetry is deliberate: be strict about what you accept from clients you can reject clearly, and tolerant in what clients should accept from you.

Scoring: Strong: Explains closed enums and strict deserialisers as the break; cites Hyrum's law; sets a client contract (tolerant reader), makes own SDKs tolerant, classifies enum additions as needing notice, versions breaking changes with deprecation and telemetry, and adds schema-diff checks in CI. Acceptable: Explains why the strict client broke and suggests telling partners to ignore unknown fields, but has no versioning or change-classification policy. Red flag: Insists additive changes can't break anyone and blames the partners, or bumps the major version for every change.

SR-TH-CLO-01 · What a closure holds, and how to inspect one during an incident

Easy · Theory · Senior (5–8 years) · closures, cells, __closure__, inspect, debugging · 4 min

Question: A callback built by a factory function is producing wrong invoices in production. You have a debugger or a REPL attached to the process. What exactly does a closure hold, and how do you inspect what this callback captured?

Answer:

A closure is a function object plus references to cells for the enclosing-scope variables it uses (its free variables). The compiler decides which names are free. At run time:

  • fn.__code__.co_freevars lists the free variable names.
  • fn.__closure__ is a tuple of cell objects in the same order, and each cell.cell_contents is the current value of that variable.
  • inspect.getclosurevars(fn) gives a readable summary: nonlocals (closure values), globals and builtins the code refers to, and unbound names.

Globals such as GST_RATE are not captured. They're looked up in the module at call time, so a module-level change affects every closure immediately. Only enclosing-function variables live in cells, and a cell is shared by every closure created in the same call of the outer function.

In an incident, this lets you confirm which configuration a long-lived callback actually holds (currency, decimals, tenant, a stale client) without guessing from the code. A common finding: the factory ran once at startup with the wrong config, and the callback has carried it ever since.

import inspect

GST_RATE = 18


def make_invoice_formatter(currency, decimals):
    template = "{cur} {amt:,.{d}f} (GST {gst}%)"

    def fmt(amount):
        return template.format(cur=currency, amt=amount, d=decimals, gst=GST_RATE)

    return fmt


fmt = make_invoice_formatter("₹", 2)
print(fmt(4500))
print(fmt.__code__.co_freevars)
print([c.cell_contents for c in fmt.__closure__])
cv = inspect.getclosurevars(fmt)
print(cv.nonlocals["currency"], cv.globals)

Output:

₹ 4,500.00 (GST 18%)
('currency', 'decimals', 'template')
['₹', 2, '{cur} {amt:,.{d}f} (GST {gst}%)']
₹ {'GST_RATE': 18}

Follow-ups:

  1. "When would you replace this closure with a small class or a frozen dataclass with __call__?" When the captured state needs to be visible: a useful repr in logs and error reports, equality in tests, pickling for multiprocessing, or more than one operation on the same state. A closure hides its state behind __closure__, which is fine for small private helpers but makes incidents harder to debug.
  2. "If the code changes GST_RATE at run time, does an existing fmt see the new rate?" Yes. GST_RATE is a global, resolved at each call, not captured. That's the opposite of what some people expect, and it's another reason to pass such values in explicitly.

Scoring: Strong: Explains cells and free variables, uses __closure__, co_freevars and inspect.getclosurevars, distinguishes captured enclosing variables from globals read at call time, and connects it to incident debugging. Acceptable: Defines a closure correctly and knows __closure__ exists. Red flag: Thinks closures copy globals too, or has no way to inspect captured state other than adding print statements and redeploying.

SR-TH-CLO-02 · The default-argument fix binds the object, not a snapshot

Easy & Tricky · Theory · Senior (5–8 years) · closures, late binding, default arguments, lambdas, aliasing · 4 min

Question: To avoid late binding, a teammate registers handlers in a loop with lambda region=region, cfg=cfg: ... and says "default arguments freeze the values at definition time". Later the shared cfg dict is updated. What do the handlers see, and is there any other side effect of this fix?

Answer:

Trap: "Default arguments freeze the values, so each handler keeps limit = 100."

A default argument is evaluated once, at definition time, and stores a reference to the resulting object. For region (a string, immutable) that's effectively a snapshot. For cfg it's a reference to the same dict, and the later cfg["limit"] = 5 mutates that dict, so every handler reports 5. The trick protects against the name being rebound, not against the object being mutated.

The second side effect: defaults become part of the public signature. Anyone can call handler(cfg={...}) and override the "captured" value by accident, for example a dispatcher that passes keyword arguments it thinks the handler wants. Signature-introspecting frameworks (FastAPI, pytest, click) will also see region and cfg as parameters.

Better options:

  • functools.partial(handle, region, limit=cfg["limit"]): binds actual values, keeps them visible in .args/.keywords, and doesn't add fake parameters.
  • A factory function make_handler(region, limit) that captures only the immutable values it needs.
  • Make the config immutable (a frozen dataclass), so sharing it is harmless. Note that MappingProxyType(cfg) doesn't help: it's a read-only view that still reflects changes to cfg.

handlers = []
cfg = {"limit": 100}
for region in ["north", "south"]:
    handlers.append(lambda region=region, cfg=cfg: f"{region}:{cfg['limit']}")

cfg["limit"] = 5
print([h() for h in handlers])
print(handlers[0](cfg={"limit": 1}))

Output:

['north:5', 'south:5']
north:1

Follow-ups:

  1. "Which approach would you prefer if cfg is reloaded by rebinding (cfg = load()) rather than mutated?" Then the default-argument version keeps the old dict, which may be what you want (a stable snapshot) or a staleness bug. Decide explicitly: capture values (snapshot) or read through an accessor at call time (live config). Don't let the mechanism decide by accident.
  2. "Is late binding ever what you want?" Yes. A callback that should always use the current value of a module-level setting or a mutable registry benefits from reading at call time. The bug is relying on it unknowingly, not the mechanism itself.

Scoring: Strong: Predicts 5 for both, explains reference vs snapshot, raises the signature leak, and proposes partial or a factory capturing immutable values; knows MappingProxyType is a live view. Acceptable: Predicts the output once prompted to think about mutation, and suggests copying cfg per handler. Red flag: Insists default arguments copy values, or proposes copy.deepcopy(cfg) in every lambda default without considering what behaviour is wanted.

SR-TH-CLO-03 · Per-decoration vs per-call state in a parametrised decorator

Medium · Theory · Senior (5–8 years) · closures, decorators, nonlocal, retries, shared state, thread safety · 6 min

Question: A @retry(times=3) decorator keeps attempts_left in the factory's scope and decrements it with nonlocal in the wrapper. The first payment push retries correctly; later pushes give up after one failure. Explain the bug in terms of closure lifetimes and state where each kind of state belongs.

Answer:

A parametrised decorator has three nested scopes with three different lifetimes:

Scope Runs Lifetime of its variables
retry(times) once per @retry(...) line as long as the decorated function exists
decorator(fn) once per decorated function same
wrapper(*args, **kwargs) every call one call

attempts_left lives in retry's scope, so one cell is shared by every call of the decorated function. T1's two failures use up the budget, and T2 starts with 1 attempt left and gives up on its first failure. Because the cell is shared, concurrent calls from several threads also race on attempts_left -= 1. The bug depends on traffic history, so it's invisible in a unit test that calls the function once.

Rule: configuration (times, backoff policy) belongs in the outer scopes. Per-call state (attempt counters, start time, the last exception) is local to wrapper, for example for attempt in range(1, times + 1):. State deliberately shared across calls (a circuit breaker's failure count, metrics) can live in an outer scope, but then it's shared mutable state and needs a lock, and it's often clearer as an explicit object, such as a CircuitBreaker instance passed to the decorator.

For the real retry: retry only on errors that are safe to retry, on idempotent operations or with an idempotency key; add backoff with jitter; and preserve the original exception.

import functools


def retry(times):
    attempts_left = times                     # per decoration, not per call!

    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            nonlocal attempts_left
            while True:
                try:
                    return fn(*args, **kwargs)
                except ConnectionError:
                    attempts_left -= 1
                    if attempts_left <= 0:
                        raise
        return wrapper
    return decorator


failures = {}


@retry(times=3)
def push(txn):
    failures[txn] = failures.get(txn, 0) + 1
    if failures[txn] <= 2:                    # every txn flakes twice
        raise ConnectionError(f"{txn}: gateway reset")
    return f"{txn} ok"


for txn in ["T1", "T2"]:
    try:
        print(push(txn))
    except ConnectionError as e:
        print("gave up:", e)

Output:

T1 ok
gave up: T2: gateway reset

Follow-ups:

  1. "Write the corrected wrapper loop." for attempt in range(1, times + 1): try: return fn(*args, **kwargs) except ConnectionError: if attempt == times: raise; time.sleep(backoff(attempt)). Here times is read-only configuration from the closure, and attempt is local to each call.
  2. "How would you test a decorator so this bug is caught?" Call the decorated function several times in one test, each call with its own failure pattern, and assert each call gets the full retry budget. Also test concurrent calls with a fake that records attempts per call.

Scoring: Strong: Maps the three scopes to their lifetimes, explains the shared cell (plus the thread race), states where config, per-call and shared state belong, and mentions idempotency for retries. Acceptable: Finds that the counter must be reset per call and moves it into the wrapper. Red flag: Fixes it by resetting attempts_left at the end of a successful call (still shared and racy), or can't say why only later calls fail.

SR-TH-CLO-04 · A generator expression binds its first iterable early and everything else late

Medium & Tricky · Theory · Senior (5–8 years) · closures, late binding, generator expressions, laziness · 5 min

Question: A report builder creates big = (r for r in rows if r > threshold), then, before consuming it, the same function reassigns both threshold and rows for the next section. Which rows and which threshold does list(big) use?

Answer:

Trap: either "both values at creation time" or "both values at consumption time".

It's split. A generator expression is compiled as a nested function, a closure. Only the outermost iterable (rows) is evaluated immediately, in the enclosing scope, and passed into the generator as an argument. Everything else (the if condition, the element expression, any inner for iterables) is evaluated lazily when items are pulled, and reads enclosing variables through closure cells with their current values. So list(big) iterates the original list [500, 1500, 2500] with the new threshold = 2000, giving [2500].

The design is deliberate: evaluating the source early means errors in obtaining it (a bad file path, a failed query) surface where the expression is written, while the per-item work stays lazy.

Where it bites: building several lazy filters or pipelines in a loop (each closes over the loop's variables and sees only their final values), or creating a generator and handing it to a consumer that runs later, after the parameters changed.

Fixes: consume immediately when the parameters are about to change, use a list comprehension when the data is small, or bind the parameters explicitly in a generator function: def above(rows, threshold): for r in rows: if r > threshold: yield r. Here the arguments are evaluated at call time and can't change underneath it.

threshold = 1000
rows = [500, 1500, 2500]
big = (r for r in rows if r > threshold)

threshold = 2000
rows = [5000]
print(list(big))

Output:

[2500]

Follow-ups:

  1. "Does the same split apply to a list comprehension in Python 3.12, given PEP 709 inlines comprehensions?" For a list comprehension it doesn't matter, because it runs to completion immediately and nothing changes in between. PEP 709 inlines list, dict and set comprehensions but not generator expressions, which still have their own frame because they must suspend.
  2. "What happens if the outermost iterable expression itself raises?" The exception is raised at the line that creates the generator expression, not at consumption, because that part is evaluated eagerly. An error in the condition or an inner iterable is raised later, during iteration, often far from where the expression was written.

Scoring: Strong: Correct output, explains the eager outermost iterable vs late-bound closure reads, why it's designed that way, and offers a generator-function fix. Acceptable: Gets the output after reasoning, knows generators are lazy, but can't explain why rows didn't change. Red flag: Answers [5000] or [1500, 2500] and can't revise with the closure model.

SR-TH-CLO-05 · A cached closure that leaks one user's data into another's request

Hard · Theory · Senior (5–8 years) · closures, caching, request-scoped state, contextvars, security, data leaks · 7 min

Question: To avoid rebuilding renderers, a statements service caches a closure per locale. The factory reads the current user from request context while building the closure. After deploy, a support ticket says customer Ravi saw Anita's user ID in his statement header. Explain the bug, why it's a security incident and not just a correctness bug, and the design rules that prevent it.

Answer:

The renderer closure captured request-scoped data (user_id) when it was built, and was then stored in a cache that outlives the request. The cache key is locale only, so every later request with the same locale gets Anita's closure, with her user ID baked into its cell. That's cross-user data exposure: a reportable privacy incident, not just a wrong value. It's also hard to reproduce, because it depends on which user warmed the cache in each worker process.

The same pattern appears with:

  • a DB session or transaction captured in a callback that runs after the request closed (use-after-close, or writes in the wrong transaction);
  • an auth token captured in a cached HTTP client;
  • a tenant ID captured in a background task or an event-bus subscription.

Rules to set as a reviewer:

  1. A cached value may depend only on its cache key. If the closure reads anything else (user, tenant, locale-plus-plan), either add it to the key or stop capturing it.
  2. Long-lived closures capture only request-independent, immutable configuration. Per-request data is passed as call arguments, e.g. render(user_id, amount).
  3. If code deep in a call stack needs request context, read it at call time from a contextvars.ContextVar (asyncio-safe) instead of capturing it when an object is built.
  4. Background tasks receive IDs, not sessions or user objects, and open their own resources.

To detect it: inspect.getclosurevars on cached callables in a test, and integration tests that serve two different users in sequence from one process and assert no data crosses over.

current_user = {"id": None}        # stands in for request-scoped context
_renderers = {}


def statement_renderer(locale):
    if locale not in _renderers:
        user_id = current_user["id"]               # captured at build time
        def render(amount):
            return f"[{locale}] statement for {user_id}: ₹{amount}"
        _renderers[locale] = render
    return _renderers[locale]


def handle_request(user_id, locale, amount):
    current_user["id"] = user_id
    return statement_renderer(locale)(amount)


print(handle_request("U-ANITA", "en-IN", 1200))
print(handle_request("U-RAVI", "en-IN", 560))

Output:

[en-IN] statement for U-ANITA: ₹1200
[en-IN] statement for U-ANITA: ₹560

Follow-ups:

  1. "Why does contextvars fix the asyncio case where a plain global or thread-local would not?" Each asyncio task runs in a copy of the context taken when it was created, so a ContextVar.set in one request's task isn't visible in another's, even on the same thread. A thread-local is shared by every coroutine on that thread, and a global by everything in the process.
  2. "How would you respond operationally once this is found?" Roll back or hotfix (drop the cache), estimate the exposure window and affected users from logs, follow the incident and privacy notification process (in India, DPDP Act obligations), and add the regression test. Then review other caches for the same pattern.

Scoring: Strong: Identifies request data captured in a closure that outlives the request, the cache key not covering dependencies, the security impact, and gives clear rules (key completeness, pass per-request data as arguments, contextvars, IDs to background tasks). Acceptable: Finds the captured user_id and moves it to a call argument, without generalising the rule. Red flag: Fixes it by clearing the cache after each request, or treats it as a minor display bug.

SR-TH-CLO-06 · Closures are not private, and their cells can be rewritten

Hard & Tricky · Theory · Senior (5–8 years) · closures, cells, security, secrets, error reporting, encapsulation · 5 min

Question: A payments SDK hides its signing secret inside a closure (sign = make_signer(secret)) and the author says the key is "private: nothing outside the factory can read or change it". Is that true? What are the practical consequences for handling secrets?

Answer:

Trap: "Captured variables are like private fields: only the inner function can reach them."

Neither half is true. Anyone with a reference to sign can read the secret via sign.__closure__[0].cell_contents or inspect.getclosurevars(sign). Since Python 3.7, cell_contents is also writable, so code can swap the key for every holder of that closure. Python has no in-process privacy boundary: closures, _private names and name-mangled __attrs are conventions against accidents, not protection against code running in the same interpreter.

The practical risk is less about an attacker poking at cells and more about accidental disclosure:

  • Error trackers and rich tracebacks (Sentry, rich, better_exceptions) capture frame locals. A frame's locals include its free variables, so an exception inside sign ships the secret to a third-party service.
  • Debug logging of objects with default reprs, pickled callbacks in task queues, and heap dumps all expose captured values.

Practices to standardise:

  • Keep secrets in a dedicated type that redacts its repr and str (pydantic's SecretStr, or a small class), and unwrap at the last moment.
  • Configure error reporting to scrub locals or variables matching secret patterns, or disable local-variable capture in payment paths.
  • Load secrets from a secret manager with short lifetimes, and rotate keys. Assume anything in process memory can leak.

import hashlib
import hmac


def make_signer(secret: bytes):
    def sign(payload: bytes) -> str:
        return hmac.new(secret, payload, hashlib.sha256).hexdigest()[:12]
    return sign


sign = make_signer(b"live-key-123")
print(sign.__closure__[0].cell_contents)
before = sign(b"order=A-101")
sign.__closure__[0].cell_contents = b"attacker-key"
print(before == sign(b"order=A-101"))

Output:

b'live-key-123'
False

Follow-ups:

  1. "Is there any legitimate use for writing cell_contents?" Rarely: debugging tools, hot-patching in a REPL during an incident, or some testing utilities. In application code it's a red flag. If state needs to change, make it an explicit attribute of an object.
  2. "Would a class with __slots__ and a name-mangled __secret be better protected?" Not against in-process code: obj._Signer__secret still reads it, and the slot descriptor is on the class. The real gains come from a redacting repr, not storing the raw secret longer than needed, and scrubbing error reports.

Scoring: Strong: Shows closures are readable and (since 3.7) writable, explains that there's no in-process security boundary, and focuses on realistic leaks (frame locals in error reporters, logs) with concrete mitigations. Acceptable: Knows __closure__ exposes captured values and suggests a secret type or vault. Red flag: Defends closures or name mangling as a security mechanism.

SR-TH-CMP-01 · When you reject a comprehension in code review

Easy · Theory · Senior (5–8 years) · comprehensions, readability, code review, side effects, team standards · 4 min

Question: You're writing the team's Python style guide. When is a comprehension the right tool, and which comprehensions would you ask authors to rewrite in review?

Answer:

A comprehension is right when it builds a collection from one transform and at most one filter, and reads as a single sentence: [o.id for o in orders if o.is_paid], {u.id: u for u in users}.

Ask for a rewrite (an explicit loop or a named helper) when:

  • It's used for side effects: [db.save(r) for r in rows] builds a throwaway list of Nones and hides that this is a write loop. Use a for loop.
  • It has more than two for/if clauses, or nested comprehensions inside comprehensions. The left-to-right nesting order stops being obvious.
  • The condition or expression needs a comment, or repeats an expensive call ([f(x) for x in xs if f(x)]). Extract a named function, or use := sparingly when it's still readable.
  • It needs error handling per item. try/except can't go inside a comprehension, and one bad row aborts the whole thing. A loop or generator function can log or dead-letter the bad item and continue.
  • The result is huge and consumed once. Use a generator expression, or stream it, instead of materialising.

The reasoning to share with the team: comprehensions are a readability feature first. Their speed advantage over an equivalent append loop is real but modest, and it rarely matters next to I/O. When the comprehension is harder to read than the loop, the loop wins.

Follow-ups:

  1. "Would you enforce any of this with tooling?" Partly. Ruff and flake8-comprehensions flag unnecessary comprehensions and side-effect patterns, and complexity limits catch some nesting. Readability judgements stay with review, so put examples in the style guide.
  2. "[x for x in rows if x.amount] vs filter(None, ...) vs a loop: which do you prefer for a filter-only case?" The comprehension, because the condition is explicit. filter with a lambda is less readable, and filter(None, ...) relies on truthiness, which may silently drop legitimate zero amounts.

Scoring: Strong: Gives a clear positive rule plus concrete rejection criteria (side effects, nesting, repeated calls, per-item error handling, size), and keeps performance in perspective. Acceptable: Has sensible readability instincts but few concrete criteria. Red flag: Prefers comprehensions everywhere "because they're faster", including for side effects.

SR-TH-CMP-02 · `any([...])` calls the expensive check on every item

Easy & Tricky · Theory · Senior (5–8 years) · comprehensions, generator expressions, short-circuit, any, performance · 4 min

Question: An onboarding flow checks PANs against a blocklist service with if any([is_blocked(p) for p in pans]):. Each check is a ~200 ms network call. The first PAN in a typical batch of four is the blocked one. How many calls are made, and how does the generator-expression version differ?

Answer:

Trap: "any stops at the first True, so only one call is made either way."

any does short-circuit, but only over values it is given lazily. With square brackets, the list comprehension runs completely before any starts, so all four network calls happen (about 800 ms), and any then stops at the first element of a list that already exists. With a generator expression, any(is_blocked(p) for p in pans), each call happens only when any asks for the next value, so it stops after the first call (about 200 ms). all, next, min/max with early exits in your own loops, and itertools.takewhile behave the same way.

The opposite case matters too: sometimes you want every check to run, for example validating a form and reporting all errors, or checks with side effects such as audit logging. Then build the full list explicitly and name it, so nobody "optimises" it into a generator later.

At senior level, also question the design: four sequential 200 ms calls is the bigger problem. A batch endpoint, concurrent calls, or a cached local copy of the blocklist would change the latency far more than short-circuiting.

calls = []


def is_blocked(pan):
    calls.append(pan)            # imagine a 200 ms call to a KYC service
    return pan.startswith("ZZ")


pans = ["ZZ1234", "AB5678", "CD9012", "EF3456"]

print(any([is_blocked(p) for p in pans]), len(calls))
calls.clear()
print(any(is_blocked(p) for p in pans), len(calls))

Output:

True 4
True 1

Follow-ups:

  1. "Linters flag any([...]). Is the rewrite always safe?" Safe for correctness when the per-item calls have no side effects you rely on. If they do (logging, counting, mutating), switching to a generator changes behaviour by skipping calls. Check before applying the auto-fix.
  2. "How would you make this check fast if the blocklist has 2 lakh entries?" Load it into a set (or a Bloom filter if memory matters) and refresh it periodically, making each check an O(1) local lookup. Then a short-circuit vs full scan barely matters.

Scoring: Strong: Says 4 vs 1 calls with the correct reason (eager list built before any), names the cases where full evaluation is wanted, and raises the bigger latency design issue. Acceptable: Gets the counts and the reason. Red flag: Thinks both versions make one call, or that list comprehensions are lazy.

SR-TH-CMP-03 · A dict comprehension silently keeps the last duplicate key

Medium · Theory · Senior (5–8 years) · dict comprehensions, duplicates, data contracts, data quality, pandas · 5 min

Question: A credit-limit loader builds by_pan = {r["pan"]: r for r in rows} from a partner's daily file. Downstream, one customer's limit dropped from ₹50,000 to ₹5,000 overnight. The file had a duplicate PAN row. What did the comprehension do, and how would you design this step so duplicates can't go unnoticed?

Answer:

A dict comprehension assigns by_pan[key] = value once per row. A duplicate key overwrites silently: the last row wins. The key keeps the position of its first insertion, though, so the dict's order doesn't reveal that anything was replaced. Set comprehensions collapse duplicates in the same silent way. Nothing raises and nothing is logged, and the record count just shrinks (3 rows, 2 keys).

This is a data-contract problem more than a Python one. Uniqueness of the business key is an assumption about the partner's data, and it must be checked, not implied by a dict:

  • Count before building: Counter(r["pan"] for r in rows), and act on any key with a count above 1.
  • Decide the policy explicitly: reject the file, keep the latest row by a timestamp or sequence column, keep the first, or merge fields. "Last row in file order wins" is rarely the right business rule.
  • Emit metrics (rows in, unique keys, duplicates) and alert on unexpected changes, and quarantine duplicates for review.
  • In pandas, the equivalents are df.duplicated("pan", keep=False), set_index("pan", verify_integrity=True), and merge(..., validate="one_to_one"), all of which raise instead of silently choosing.

from collections import Counter

rows = [
    {"pan": "ABCDE1234F", "name": "Priya", "limit": 50_000},
    {"pan": "PQRSX5678K", "name": "Arjun", "limit": 20_000},
    {"pan": "ABCDE1234F", "name": "Priya S", "limit": 5_000},
]

by_pan = {r["pan"]: r for r in rows}
print(len(rows), len(by_pan), by_pan["ABCDE1234F"]["limit"])
print(list(by_pan))

dupes = [k for k, n in Counter(r["pan"] for r in rows).items() if n > 1]
print(dupes)

Output:

3 2 5000
['ABCDE1234F', 'PQRSX5678K']
['ABCDE1234F']

Follow-ups:

  1. "Write a helper that builds the index but raises on duplicates." def index_unique(rows, key): out = {}; for r in rows: k = r[key]; if k in out: raise ValueError(f"duplicate {key}: {k}"); out[k] = r; return out. A loop is clearer than a comprehension here because it needs a check per item.
  2. "The partner says duplicates are corrections and the later row is authoritative. Is last-wins fine then?" Only if file order is guaranteed to be chronological. Ask for a sequence number or timestamp and pick the maximum explicitly. Record the agreement in the data contract and test it, so a reordered file doesn't flip limits.

Scoring: Strong: Explains last-wins with first-insertion order, frames it as an unchecked uniqueness assumption, proposes an explicit duplicate policy with metrics and alerting, and knows the pandas validation options. Acceptable: Knows the last row wins and adds a duplicate check. Red flag: Expects an error on duplicate keys, or thinks the first row wins.

SR-TH-CMP-04 · The `next()`-in-a-generator-expression chunking idiom and PEP 479

Medium & Tricky · Theory · Senior (5–8 years) · generator expressions, StopIteration, PEP 479, chunking, itertools.batched, islice · 5 min

Question: An old batching helper does chunk = list(next(it) for _ in range(size)) in a loop and stops when the chunk is empty. It was copied from a Python 3.6 codebase into a new 3.12 upload job. What happens on the last, partial batch?

Answer:

Trap: "It yields the short final chunk [6], then stops." That was the behaviour before Python 3.7.

When it runs out, next(it) raises StopIteration inside the generator expression's frame. Before PEP 479, a StopIteration escaping a generator body looked like a normal end, so the generator expression silently ended early and the idiom "worked", while hiding real bugs elsewhere. Since 3.7, a StopIteration that escapes a generator's body (including a generator expression) is converted to RuntimeError: generator raised StopIteration. The upload job processes the full batches, then crashes on the partial one.

A related, nastier variant: in ordinary (non-generator) code, a stray StopIteration from a bare next() propagates normally. If that code is called from inside a caller's for loop body or __next__, it can end the caller's iteration early, without an error, losing data.

Fixes:

  • Use the standard tools: itertools.batched(it, n) (3.12+, yields tuples) or while chunk := list(islice(it, size)):. islice stops cleanly at exhaustion.
  • Never call bare next(it) where exhaustion is possible. Use next(it, sentinel) or handle StopIteration locally.

from itertools import batched, islice


def chunks(iterable, size):
    it = iter(iterable)
    while chunk := list(islice(it, size)):
        yield chunk


def chunks_legacy(iterable, size):
    it = iter(iterable)
    while True:
        chunk = list(next(it) for _ in range(size))
        if not chunk:
            return
        yield chunk


print(list(chunks(range(7), 3)))
print(list(batched(range(7), 3)))
print(list(chunks_legacy(range(7), 3)))

Output:

[[0, 1, 2], [3, 4, 5], [6]]
[(0, 1, 2), (3, 4, 5), (6,)]
RuntimeError: generator raised StopIteration

Follow-ups:

  1. "Why did the Python core developers make this a hard error rather than keep the old behaviour?" Because silently ending iteration on an accidental StopIteration (from a helper calling next() on the wrong iterator) caused data loss that was very hard to debug. Converting it to RuntimeError makes the bug visible where it happens. The deliberate way to end a generator is return.
  2. "Would [next(it) for _ in range(size)], a list comprehension, behave the same?" Not exactly. A list comprehension isn't a generator, so StopIteration propagates as itself. Inside chunks_legacy (a generator) it then escapes the generator body and becomes the same RuntimeError. Called from ordinary code it stays a StopIteration and could silently stop an enclosing for loop.

Scoring: Strong: Predicts the RuntimeError on the last batch, explains PEP 479 and why the pre-3.7 idiom "worked", knows the ordinary-code variant, and fixes with batched/islice. Acceptable: Knows StopIteration inside generators becomes RuntimeError and uses islice. Red flag: Expects [6] as the last chunk, or catches RuntimeError to restore the old behaviour.

SR-TH-CMP-05 · Where comprehension speed comes from, and when to stop optimising Python loops

Hard · Theory · Senior (5–8 years) · performance, comprehensions, PEP 709, profiling, vectorisation, memory · 7 min

Question: A teammate's PR rewrites a 2-million-row transform from a for loop with append into a list comprehension "for speed", reporting a 20% gain. Explain where that gain comes from, what PEP 709 changed in 3.12, and how you'd decide what to do next if the job still misses its SLA.

Answer:

Where the gain comes from: the comprehension avoids looking up and calling result.append on every iteration (an attribute lookup plus a method call), and it uses a dedicated LIST_APPEND bytecode instead. The per-item expression itself costs the same. So the gain is a constant-factor saving on loop overhead: significant when the per-item work is tiny, negligible when each item does real work (parsing, I/O, regexes).

PEP 709 (3.12) inlines list, dict and set comprehensions into the enclosing function, instead of creating and calling a hidden function object each time. That makes comprehensions themselves cheaper, especially small ones in hot code, while keeping the iteration variable isolated. Generator expressions are not inlined; they still need their own frame to suspend.

If the SLA is still missed, a senior engineer stops micro-optimising and measures:

  1. Profile with cProfile/pstats or a sampling profiler (py-spy, scalene) on realistic data to find where the time actually goes. Often it's parsing, I/O or one hot function, not the loop.
  2. Change the algorithm or data structure first: a set or dict lookup instead of a scan, avoiding repeated work, batching I/O.
  3. Move the loop out of Python: vectorise with NumPy or pandas (column operations run in C over contiguous memory), use builtins (sum, str.join, sorted with a key), or push the work into the database or query engine (SQL, Polars, DuckDB).
  4. Parallelise CPU-bound work with processes (or free-threaded builds where available) only after the single-core path is efficient, accounting for serialisation cost.
  5. Watch memory: a 2-million-element list of dicts can be several hundred MB. Streaming with generators or columnar types can matter more than raw speed.

Also check the benchmark: use timeit or pyperf with warm-up and several runs. A 20% gain from one ad-hoc run can be noise.

Follow-ups:

  1. "How would you show the reviewer that the comprehension and the loop produce identical results on production-like data?" Run both on a sampled real file in a test and compare outputs (or hashes), and keep that as a regression test if the transform is critical. Performance changes shouldn't change behaviour silently.
  2. "When is a plain loop faster than vectorised pandas?" On tiny inputs, where pandas overhead dominates, and for logic that can't be vectorised and falls back to apply with a Python function per row, which is often slower than a straightforward loop over tuples or dicts.

Scoring: Strong: Correctly attributes the gain to avoided append lookups/calls, explains PEP 709 inlining (and that genexps aren't inlined), and lays out a measure-first escalation: profile, algorithm, vectorise or push down, parallelise, with memory and benchmarking rigour. Acceptable: Knows comprehensions are faster due to less overhead and suggests vectorising, but is vague on profiling. Red flag: Thinks comprehensions run in C or in parallel, or proposes rewriting everything as comprehensions to meet the SLA.

SR-TH-CMP-06 · `await` inside a comprehension runs the calls one after another

Hard & Tricky · Theory · Senior (5–8 years) · asyncio, async comprehensions, gather, TaskGroup, concurrency, latency · 5 min

Question: An async endpoint builds rates = [await fetch_rate(c) for c in cities] and the author says it's "concurrent because it's async". Each call takes ~300 ms and there are 10 cities. How long does it take, and how would you write it properly for production?

Answer:

Trap: "A comprehension with await fires all the calls together."

An await inside a comprehension makes it an async comprehension, but it still evaluates one element at a time: it starts fetch_rate("Pune"), waits for it to finish, then starts the next one. async lets other tasks run while this one waits; it doesn't make this loop concurrent. Ten calls take about 3 s, not 300 ms. The same applies to [x async for x in agen] and to a plain for loop with await.

Concurrency needs tasks:

  • await asyncio.gather(*(fetch_rate(c) for c in cities)) wraps each coroutine in a task, runs them concurrently and returns results in input order.
  • async with asyncio.TaskGroup() as tg: (3.11+) does the same with structured concurrency: if one task fails, the rest are cancelled and an ExceptionGroup is raised. Plain gather by default propagates the first exception but leaves the other tasks running. Choose based on what a partial failure should mean.

Production concerns a senior engineer adds:

  • Bound the concurrency with an asyncio.Semaphore (or a connection-pool limit). Fanning out 10,000 requests at once will hit rate limits or exhaust sockets.
  • Per-call timeouts (asyncio.timeout) so one slow city doesn't hold the whole response.
  • Decide partial-failure semantics: fail the request, or return available rates and mark the missing ones (return_exceptions=True).

import asyncio

log = []


async def fetch_rate(city):
    log.append(f"start {city}")
    await asyncio.sleep(0)
    log.append(f"end {city}")
    return len(city)


async def main():
    cities = ["Pune", "Delhi", "Kochi"]
    seq = [await fetch_rate(c) for c in cities]
    print(log)
    log.clear()
    par = await asyncio.gather(*(fetch_rate(c) for c in cities))
    print(log)
    print(seq == par)


asyncio.run(main())

Output:

['start Pune', 'end Pune', 'start Delhi', 'end Delhi', 'start Kochi', 'end Kochi']
['start Pune', 'start Delhi', 'start Kochi', 'end Pune', 'end Delhi', 'end Kochi']
True

Follow-ups:

  1. "Show how you'd cap concurrency at 5 with gather." sem = asyncio.Semaphore(5), then async def limited(c): async with sem: return await fetch_rate(c), then await asyncio.gather(*(limited(c) for c in cities)). All tasks are created, but only 5 are inside fetch_rate at once.
  2. "Is the sequential version ever correct?" Yes: when each call depends on the previous result, when the downstream service requires strict ordering or allows only one request at a time, or to deliberately limit load. Then write it as an explicit for loop with a comment, so nobody mistakes it for an accident.

Scoring: Strong: Says ~3 s and explains sequential evaluation; uses gather or TaskGroup, knows their different failure semantics, and adds bounded concurrency, timeouts and a partial-failure policy. Acceptable: Knows to use asyncio.gather for concurrency. Red flag: Believes the async comprehension is concurrent, or "fixes" it with threads.

SR-TH-COL-01 · Sessionising a clickstream with pairwise and accumulate

Easy · Theory · Senior (5–8 years) · itertools, pairwise, accumulate, streaming, sessionisation, data pipelines · 4 min

Question: You own a pipeline that turns a time-sorted clickstream into sessions: a new session starts whenever the gap between two consecutive clicks of a user exceeds 30 minutes. A teammate's version uses index arithmetic (clicks[i] - clicks[i - 1]) over a list loaded from the whole day's file. Which itertools tools would you use instead, and what properties do you get?

Answer:

The two building blocks are itertools.pairwise (3.10+), which yields consecutive pairs (a, b), (b, c), ..., and itertools.accumulate, a running fold (a running sum by default).

  1. pairwise(clicks) gives each click with its predecessor, so the gap is b - a. No indices, no off-by-one at the start.
  2. Turn each gap into a 1 ("starts a new session") or 0, prefixing a 1 for the very first click.
  3. accumulate over those flags yields a session number per click: it increments exactly where a new session starts.

What this buys you as the owner of the job:

  • Streaming. Both are lazy and hold O(1) state, so the same code works on a generator over a 50 GB file or a DB cursor; you never need len() or random access. The snippet uses lists only to print.
  • Correct edges. An empty input yields nothing; a single click is one session. Index loops usually get one of these wrong.
  • Composability. Per user, wrap it in groupby(rows, key=user_id) on input already sorted by (user, ts); that ordering is a data contract the upstream must guarantee (or that you enforce and alert on).

Limits worth stating: this assumes events arrive in order. With late or out-of-order events you need a watermark and buffering, or you sessionise in a stream processor / SQL window (LAG(ts) OVER (PARTITION BY user ORDER BY ts)), which is the same algorithm.

from datetime import datetime, timedelta
from itertools import pairwise, accumulate

GAP = timedelta(minutes=30)
clicks = [datetime(2026, 10, 4, h, m) for h, m in
          [(9, 0), (9, 10), (9, 25), (11, 0), (11, 5), (14, 40)]]

# 1 marks "this click starts a new session", 0 means "same session"
starts = [1] + [int(b - a > GAP) for a, b in pairwise(clicks)]
session_ids = list(accumulate(starts))
for t, s in zip(clicks, session_ids):
    print(t.strftime("%H:%M"), "session", s)

Output:

09:00 session 1
09:10 session 1
09:25 session 1
11:00 session 2
11:05 session 2
14:40 session 3

Follow-ups:

  1. "How would you write this as a pure generator, without building starts as a list?" Use itertools.chain([1], (int(b - a > GAP) for a, b in pairwise(clicks))) and feed it to accumulate. But pairwise and the outer zip both need the clicks, so either tee the input (fine here, since they advance in step) or write a small generator that keeps prev and session_id in two locals. The explicit generator is often clearer for reviewers.
  2. "The product team now wants sessions to also break at midnight IST. Where does that go?" Into the "starts a new session" predicate: b - a > GAP or b.astimezone(IST).date() != a.astimezone(IST).date(). Timestamps should be aware UTC in the pipeline and converted to IST only for this business rule, so a server's local timezone can never change the result.

Scoring: Strong: Uses pairwise + accumulate (or an equivalent O(1)-state generator), points out laziness and edge cases, and names the sorted-input requirement as a contract, including what to do about late events. Acceptable: Produces a correct streaming solution with a manual prev variable but doesn't discuss ordering assumptions. Red flag: Keeps the full-day list and index loop, or proposes groupby on the timestamp itself as if that formed sessions.

SR-TH-COL-02 · zip() silently drops the unmatched tail in a reconciliation

Easy & Tricky · Theory · Senior (5–8 years) · zip, strict, itertools.zip_longest, reconciliation, data integrity, linting · 4 min

Question: A reconciliation job pairs bank transaction references with ledger amounts using zip(bank_txns, ledger_amounts). One night the ledger export is missing a row. What does the job do, and what would you change as a team standard?

Answer:

Trap: "zip raises if the lengths differ" or "the missing row shows up as None". Neither. zip stops at the shortest input and drops the rest without any error. Here UTR104 simply vanishes from the reconciliation, and the report says "3 of 3 matched". Silent data loss is the worst outcome for a reconciliation.

Fixes, depending on intent:

  • zip(a, b, strict=True) (3.10+) when the lengths must match: it raises ValueError the moment one input is exhausted early. That's the right default for parallel columns, header/row pairs, IDs and predictions.
  • itertools.zip_longest(a, b, fillvalue=MISSING) when a mismatch is expected and you want to report the unmatched items.
  • Better still for reconciliation, don't pair by position at all: join on a key (dict keyed by UTR, or a DataFrame merge with indicator=True) so that ordering differences can't misalign rows either. Positional pairing of two independently produced exports assumes the same order, which no one guaranteed.

Team standard: enable the linter rule that flags zip() without an explicit strict= (Ruff/flake8-bugbear B905), so every call site states its intent. dict(zip(header, row)) for CSV parsing deserves strict=True too: a short row otherwise silently drops columns.

bank_txns = ["UTR101", "UTR102", "UTR103", "UTR104"]
ledger_amounts = [500, 1200, 750]          # one ledger row is missing

pairs = list(zip(bank_txns, ledger_amounts))
print(len(pairs), pairs[-1])

try:
    list(zip(bank_txns, ledger_amounts, strict=True))
except ValueError as e:
    print("ValueError:", e)

Output:

3 ('UTR103', 750)
ValueError: zip() argument 2 is shorter than argument 1

Follow-ups:

  1. "With strict=True on two generators, when exactly is the error raised, and what has already happened by then?" Only when the shorter input runs out, i.e. after all the matching pairs have been yielded and processed. If the loop body writes to a database, those writes have already happened. For a job that must be all-or-nothing, check counts first (if the inputs are sized), or stage results and commit only after the loop completes without error.
  2. "Which other built-ins truncate silently in the same way?" map(f, a, b) stops at the shortest input (3.14 adds a strict flag to it as well), and so do itertools.starmap over a zip, and dict(zip(...)). Slices past the end and str.split with maxsplit also never raise. Anywhere a length mismatch means corrupt data, make the check explicit.

Scoring: Strong: States that zip truncates silently, gives strict=True and zip_longest with when to use each, and questions positional pairing itself in favour of a key-based join. Proposes a lint rule as the team-level fix. Acceptable: Knows about truncation and strict=True but doesn't address positional alignment or partial side effects. Red flag: Believes zip pads with None or raises, or fixes it with an assert len(a) == len(b) that only works for lists.

SR-TH-COL-03 · An extensible serializer with functools.singledispatch

Medium · Theory · Senior (5–8 years) · functools.singledispatch, API design, extensibility, serialization, JSON, Decimal · 6 min

Question: Your team's SDK has a to_jsonable(obj) helper with a growing if isinstance(...) elif ... chain, and other teams keep sending PRs to add their types. How would you redesign it with functools.singledispatch, and what are the trade-offs?

Answer:

@singledispatch turns a function into a generic function that picks an implementation by the type of the first argument, walking the MRO, so a handler registered for a base class (or an ABC such as collections.abc.Mapping) covers subclasses. The undecorated body is the fallback.

Design:

  • The base function raises a clear TypeError (never str(obj) silently: that's how Decimal("1499.50") becomes a float or a repr in a payments payload).
  • Implementations register with @to_jsonable.register using the type annotation of the first parameter (unions work from 3.11).
  • Other teams extend it from their own modules without touching yours: the open/closed principle, and no more merge conflicts in one giant function.
  • Pass it to json.dumps(default=to_jsonable), which calls it only for types json doesn't already handle.

Trade-offs to make explicit:

  • Registration happens at import time. If the module that registers Paise isn't imported, you get the fallback. Document where registrations live, or import them in the package's __init__.
  • MRO resolution can surprise. datetime is a subclass of date, so without its own registration a datetime goes through the date handler and loses its time. Register the more specific type too, and test it.
  • It's a global registry: two teams can register conflicting handlers for the same type; the last import wins. Ambiguous ABC matches raise RuntimeError.
  • Dispatches on one argument only, on the class (not on typing.Protocols or values). singledispatchmethod exists for methods.

Alternatives: a protocol method (__json__()) that types implement themselves; pydantic models with custom serializers when the shapes are schema-driven; an explicit registry dict when you want registration to be visible in one place.

from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from functools import singledispatch
import json


@singledispatch
def to_jsonable(obj):
    raise TypeError(f"no serializer registered for {type(obj).__name__}")


@to_jsonable.register
def _(obj: Decimal):
    return str(obj)            # never float for money


@to_jsonable.register
def _(obj: date):
    return obj.isoformat()


# Another team's module can extend it without editing ours:
@dataclass
class Paise:
    value: int


@to_jsonable.register
def _(obj: Paise):
    return {"paise": obj.value}


payload = {"amount": Decimal("1499.50"), "due": date(2026, 10, 4), "fee": Paise(1800)}
print(json.dumps(payload, default=to_jsonable))
try:
    json.dumps({"tags": {"gst"}}, default=to_jsonable)
except TypeError as e:
    print("TypeError:", e)

Output:

{"amount": "1499.50", "due": "2026-10-04", "fee": {"paise": 1800}}
TypeError: no serializer registered for set

Follow-ups:

  1. "How do you test that every type your service emits has a serializer?" Property-style test: build one instance of each domain type (or walk your pydantic/dataclass models) and assert json.dumps(x, default=to_jsonable) succeeds and round-trips. Also assert to_jsonable.dispatch(datetime) is not to_jsonable.dispatch(date) once a datetime handler exists, so a refactor can't silently fall back to the date handler.
  2. "Why not just json.dumps(payload, default=str)?" It "works" for everything, which is the problem: sets become "{'gst'}", objects become reprs with memory addresses, and money becomes whatever str() gives. Bugs ship as valid-looking JSON. An explicit failure for unknown types is what keeps the contract honest.

Scoring: Strong: Explains dispatch on the first argument's type via MRO, shows extension from another module, and raises the import-time registration, datetime/date subclassing and global-registry trade-offs. Mentions an alternative and when it fits better. Acceptable: Uses singledispatch correctly and explains extensibility, but misses the import-time and subclass pitfalls. Red flag: Suggests default=str, or a dict keyed by type(obj) without realising it ignores subclasses, with no awareness of the trade-offs.

SR-TH-COL-04 · Paging a generator with islice skips a whole page

Medium & Tricky · Theory · Senior (5–8 years) · itertools.islice, iterators, generators, pagination, batching · 5 min

Question: An export endpoint streams orders from a generator over a DB cursor. A developer adds paging with page1 = list(islice(orders, 0, 3)) and then page2 = list(islice(orders, 3, 6)), mirroring list slicing. In review, what do you expect page2 to contain, and how should it be written?

Answer:

Trap: "islice(it, 3, 6) returns items 3 to 5, just like lst[3:6]." On a list, indices are absolute. On an iterator, islice counts from wherever the iterator is now. After page 1 has consumed ORD-1..3, islice(orders, 3, 6) skips three more (ORD-4..6, consumed and thrown away) and then takes up to three. Only ORD-7 is left, so page 2 is ['ORD-7'], and the next call finds nothing. In production that's an export that silently loses every other page.

The mental model: an iterator is a cursor, and islice advances it. start means "discard this many from the current position", not "start at absolute index". Fixes:

  • Take consecutive pages from the current position: list(islice(orders, page_size)) in a loop until it returns an empty list, or simply itertools.batched(orders, page_size) (3.12+).
  • If callers need random access ("give me page 7"), an iterator is the wrong abstraction. Page in the query with keyset pagination (WHERE (created_at, id) > (:last_ts, :last_id) ORDER BY created_at, id LIMIT :n) so each page is an independent, resumable request.

The same bug appears whenever one iterator is shared by two consumers: a log line that does len(list(rows)), a validation pass before the real pass, or two islices over one csv.reader.

from itertools import islice


def fetch_orders():
    yield from (f"ORD-{i}" for i in range(1, 8))


orders = fetch_orders()
page_size = 3
page1 = list(islice(orders, 0, page_size))
page2 = list(islice(orders, page_size, 2 * page_size))   # "the next page"
print(page1)
print(page2)
print(list(orders))

Output:

['ORD-1', 'ORD-2', 'ORD-3']
['ORD-7']
[]

Follow-ups:

  1. "How would you make an API that accepts "any iterable of orders" robust to this class of bug?" Decide and document whether it needs one pass or several. If several, materialise once (orders = list(orders)) or accept a Sequence / a callable that produces a fresh iterator. If one pass, structure the code so exactly one loop consumes it. Tests should pass a generator, not a list, since lists hide this bug.
  2. "islice(orders, 1_000_000, None) to resume after a crash: what does it cost?" It still pulls and discards a million rows from the cursor: O(start) work and I/O. Resumption should be a query predicate (keyset cursor) or a stored offset the source can seek to, not skipping on the client.

Scoring: Strong: Predicts ['ORD-7'] and explains that islice start is relative to the iterator's current position, proposes islice(it, n)/batched for sequential pages and keyset pagination for random access, and links it to shared-iterator bugs generally. Acceptable: Realises page 2 is wrong because the generator was consumed, and fixes it with islice(orders, 3), without discussing pagination design. Red flag: Expects ['ORD-4', 'ORD-5', 'ORD-6'] and, when shown the output, suggests converting the cursor to a list for the whole export.

SR-TH-COL-05 · Layered configuration with ChainMap, and knowing where a value came from

Hard · Theory · Senior (5–8 years) · collections.ChainMap, configuration, provenance, observability, pydantic-settings · 8 min

Question: Your service resolves configuration from defaults, a YAML file, environment variables and CLI flags, highest priority last. During an incident someone asks "why is the payment timeout 10 seconds in production?" and nobody can answer quickly. How would you design config resolution, and where does collections.ChainMap fit and fall short?

Answer:

ChainMap(cli, env, file, defaults) gives you layered lookup without copying: a key is looked up in each mapping in order and the first hit wins. Useful properties:

  • Provenance for free. Because the layers stay separate (cfg.maps), you can report which layer supplied each key. That answers the incident question directly; log the resolved config with sources at startup (secrets redacted) and expose it on an admin endpoint.
  • Live views. Layers are references, not copies: changing file_cfg later is visible through the chain. That's handy for tests (cfg.new_child({...}) adds an override layer you can drop) and dangerous in production if anything mutates a layer after startup.
  • Writes and deletes go to maps[0] only, so cfg["region"] = ... lands in the CLI layer; it never modifies defaults. Deleting a key that exists only in a lower layer raises KeyError.

Where it falls short, and what the design needs on top:

  • Shallow. Nested sections aren't merged: if the file defines db: {pool: 5} and env defines db: {host: ...}, the env dict replaces the file's db entirely. You need an explicit deep merge (or flat dotted keys) for nested config.
  • No types or validation. Env vars are strings; "0" is truthy. Resolve with ChainMap (or pydantic-settings, which implements the same source-priority idea), then validate once into a typed, frozen settings object, and fail fast at startup on unknown keys or bad values.
  • Freeze after startup. Code should read the validated object, never the raw chain, so a late mutation can't change behaviour mid-flight.

The senior-level point is that configuration is part of observability: precedence rules written down, the effective value and its source inspectable, and validation at startup rather than at the first payment.

from collections import ChainMap

defaults = {"timeout_s": 30, "region": "ap-south-1", "retries": 3}
file_cfg = {"timeout_s": 10}
env_cfg = {"retries": 5}
cli_cfg = {}

cfg = ChainMap(cli_cfg, env_cfg, file_cfg, defaults)
print(cfg["timeout_s"], cfg["retries"], cfg["region"])

file_cfg["timeout_s"] = 15            # layers are live views, not copies
print(cfg["timeout_s"])

cfg["region"] = "ap-south-2"          # writes go to the FIRST mapping only
print(cli_cfg, defaults["region"])

def source_of(key):
    names = ["cli", "env", "file", "defaults"]
    return next(n for n, m in zip(names, cfg.maps) if key in m)

print({k: source_of(k) for k in sorted(cfg)})

Output:

10 5 ap-south-1
15
{'region': 'ap-south-2'} ap-south-1
{'region': 'cli', 'retries': 'env', 'timeout_s': 'file'}

Follow-ups:

  1. "An env var PAYMENTS__TIMEOUT_S="" is set by a broken Helm chart. What should happen?" Validation must reject it at startup with a message naming the key and its source ("env: PAYMENTS__TIMEOUT_S is empty, expected a positive integer"). Silently falling back to the default hides the misconfiguration; crashing at the first request is worse. Fail the readiness probe so the rollout stops.
  2. "How do you support hot-reloading part of the config without restarts?" Build a new validated, immutable settings object from the sources and swap the reference atomically; never mutate the live one. Restrict hot-reload to keys that are safe to change (timeouts, feature flags), log old → new values with their sources, and keep connection-level settings (pool sizes, hosts) restart-only.

Scoring: Strong: Describes ChainMap lookup, live views and first-map writes accurately; uses the layers for provenance; identifies shallow merging and lack of typing; and proposes validate-once into a frozen settings object with startup logging of effective values and sources. Acceptable: Uses ChainMap correctly for precedence and adds validation, but doesn't address nested sections or provenance. Red flag: Merges with {**defaults, **file, **env} everywhere config is read, reads os.environ directly across the codebase, or doesn't see why provenance matters during an incident.

SR-TH-COL-06 · An Iterable[str] parameter that happily accepts a single string

Hard & Tricky · Theory · Senior (5–8 years) · collections.abc, Iterable, API design, typing, iteration protocol, defensive checks · 7 min

Question: Your notification SDK exposes notify(user_ids: Iterable[str]). A caller passes "U17" instead of ["U17"], mypy is happy, and three SMS go out to users U, 1 and 7. A teammate proposes guarding with isinstance(user_ids, collections.abc.Iterable). Does that help? How do you design this API?

Answer:

Trap: "Type hints or an Iterable check will catch this." A str is an Iterable[str] (each character is a str), so both mypy and isinstance(x, Iterable) accept it. The proposed guard rejects nothing that matters.

And isinstance(x, Iterable) isn't even the definitive test for iterability. Iterable.__subclasshook__ only checks for __iter__. Python's iteration protocol also falls back to the old sequence protocol (__getitem__ with 0, 1, 2, ... until IndexError), so LegacyBatch below fails the check yet iter() works on it. The only reliable test is to call iter(x) and catch TypeError.

How to design it:

  • Reject str and bytes explicitly at the boundary of any API that takes a collection of strings: if isinstance(user_ids, (str, bytes)): raise TypeError(...). That's a well-known, deliberate special case, not paranoia.
  • Choose the narrowest type you need. Iterable promises only one pass. If you validate first and then send, you iterate twice and a generator would be empty the second time, so either accept Collection[str]/Sequence[str] or materialise once (ids = list(user_ids)) right after the guard. (str is also a Sequence[str], so the guard is still needed.)
  • Consider making the common case unambiguous: notify_one(user_id: str) plus notify_many(user_ids: Collection[str]), or a keyword-only *user_ids: str signature.
  • For a side-effecting API (SMS, payments), validate the elements too (non-empty, matches the ID format). That alone would have rejected "U".

from collections.abc import Iterable


def notify(user_ids: Iterable[str]) -> list[str]:
    return [f"sms->{u}" for u in user_ids]


print(notify(["U17", "U42"]))
print(notify("U17"))                      # type checkers accept this: str is Iterable[str]


class LegacyBatch:                        # iterable only via the old __getitem__ protocol
    def __init__(self, ids):
        self._ids = ids

    def __getitem__(self, i):
        return self._ids[i]


batch = LegacyBatch(["U1", "U2"])
print(isinstance(batch, Iterable), list(iter(batch)))


def notify_safe(user_ids):
    if isinstance(user_ids, (str, bytes)):
        raise TypeError("pass a collection of IDs, not a single ID")
    return [f"sms->{u}" for u in user_ids]


try:
    notify_safe("U17")
except TypeError as e:
    print("TypeError:", e)

Output:

['sms->U17', 'sms->U42']
['sms->U', 'sms->1', 'sms->7']
False ['U1', 'U2']
TypeError: pass a collection of IDs, not a single ID

Follow-ups:

  1. "Why is dict a similar hazard for an Iterable[str] parameter?" Iterating a dict yields its keys, so notify({"U17": "Priya"}) "works" and silently ignores the values, which is usually not what the caller meant. Pandas Series iterate values but in checks the index. For public APIs, accept a precise type and document what iteration means.
  2. "Can static typing express "an iterable of str, but not a str"?" Not directly; there's no negation type in Python's type system. Workarounds are an @overload for str marked @deprecated(...) (PEP 702; typing_extensions on 3.12) so type checkers warn at the call site, a NewType for IDs (UserId = NewType("UserId", str) makes Iterable[UserId] reject a plain str), or the runtime guard. The NewType approach also stops people passing phone numbers where IDs belong.

Scoring: Strong: Explains why both mypy and isinstance(Iterable) accept a str, knows the __getitem__ fallback and that iter() is the real test, adds an explicit str/bytes guard, and discusses one-pass Iterable vs Collection, element validation, and NewType or split APIs. Acceptable: Adds the str guard and explains the character iteration, but believes isinstance(x, Iterable) is a complete iterability check. Red flag: Accepts the teammate's guard as sufficient, or "fixes" it by wrapping any non-list in a list (if not isinstance(x, list): x = [x]), which then breaks tuples and generators.

SR-TH-CON-01 · Sizing a thread pool from latency, throughput and downstream limits

Easy · Theory · Senior (5–8 years) · ThreadPoolExecutor, capacity planning, Little's law, connection pools, rate limits, I/O-bound work · 5 min

Question: Your service enriches each order by calling a GST-verification API that takes about 200 ms, and you need to sustain 150 orders per second per pod. How do you choose max_workers for the thread pool, and what else must be sized with it?

Answer:

Start from Little's law: requests in flight = arrival rate × time each takes. 150/s × 0.2 s = 30 concurrent calls. Add headroom for latency spikes (p99 might be 600 ms, which needs 90 in flight to keep up), so something like 40-60 workers, with a bounded queue in front so overload shows up as fast rejections rather than unbounded latency.

Then check every limit along the path, because the smallest one wins:

  • The partner's limits: a rate limit (requests/s) or a concurrency cap. 8 pods × 60 threads = 480 concurrent calls might exceed what they allow; you need a fleet-wide budget, not a per-pod guess.
  • HTTP connection pool: requests/urllib3 keeps 10 connections per host by default. With 60 threads, the extra calls open throwaway connections (with "Connection pool is full, discarding connection" warnings), adding TLS handshakes to every call. Size pool_maxsize to match the workers; with httpx, set Limits(max_connections=...).
  • DB connections if workers also write: the DB pool must be at least as large as the number of threads that use it concurrently, or threads queue on the pool instead.
  • CPU and the GIL: 200 ms of waiting is cheap, but JSON parsing and validation per response is Python CPU work. If that grows, threads stop scaling and you see one core pegged.

Make the number configurable and observable: export queue depth, active workers, and time spent waiting for a worker vs in the call. One long-lived pool per process (never a pool per request), with timeouts on every call so a hung partner can't hold all the threads.

Follow-ups:

  1. "The partner's p99 jumps from 200 ms to 5 s during an incident. What happens to your service, and what protects it?" Required concurrency jumps 25×; all threads end up waiting on the partner, the queue fills, and unrelated requests that share the pool stall. Protection: per-call timeouts, a dedicated (bulkhead) pool for this dependency, a circuit breaker that fails fast, and a degraded mode (accept the order, verify GST asynchronously).
  2. "Would asyncio change the sizing logic?" The arithmetic is identical; Little's law doesn't care. Asyncio makes high concurrency cheaper (no thread per call), so you'd cap concurrency with a semaphore sized by the same downstream limits instead of by thread count.

Scoring: Strong: Derives concurrency from rate × latency with tail headroom, cross-checks partner limits fleet-wide, HTTP and DB connection pools and CPU, and insists on timeouts, bulkheads and metrics. Acceptable: Uses rate × latency to pick a number but misses connection-pool sizing or fleet-wide limits. Red flag: Picks a number by intuition ("cores × 2") or says "more threads is always faster for I/O".

SR-TH-CON-02 · Executor.map consumes the whole input before returning

Easy & Tricky · Theory · Senior (5–8 years) · concurrent.futures, Executor.map, backpressure, memory, generators, batching · 5 min

Question: A backfill pushes 1 crore rows from a DB cursor to an API with for r in pool.map(push, read_cursor()):. The developer expects it to stream: read a few rows, push them, read more. Memory climbs to many GB before the first result is logged. Why?

Answer:

Trap: "Executor.map is lazy like the built-in map." Its results are yielded lazily, in input order, but the submission is eager: the implementation does [self.submit(fn, *args) for args in zip(*iterables)] before returning. So pool.map drains the entire cursor up front and creates one Future (holding the row, and later its result) per input. For 1 crore rows that's 1 crore futures in memory and a work queue with no backpressure. The output below shows all 1 lakh rows pulled before the first result is read.

Also relevant:

  • Results come back in input order, so one slow item holds back every later result even if they're done (head-of-line blocking).
  • An exception surfaces only when iteration reaches that item; by then everything else has been submitted and may already have run.

Fixes:

  • Bounded submission: keep at most N futures in flight. Submit N, then wait(..., return_when=FIRST_COMPLETED), handle what's done, and top up from the iterator.
  • Batch with itertools.batched(rows, 500) and map over batches (also fewer API calls if the API supports bulk).
  • On 3.14+, pool.map(fn, it, buffersize=N) limits how far submission runs ahead.
  • For a job like this, also make each push idempotent and record a resumable checkpoint (last row ID), since a crash mid-way is likely at this scale.

from concurrent.futures import ThreadPoolExecutor

pulled = 0


def read_cursor(n):
    global pulled
    for i in range(n):
        pulled += 1
        yield i


def push(row):
    return row * 2


with ThreadPoolExecutor(max_workers=4) as pool:
    results = pool.map(push, read_cursor(100_000))
    print("rows pulled before the first result is read:", pulled)
    print("first result:", next(results))

Output:

rows pulled before the first result is read: 100000
first result: 0

Follow-ups:

  1. "Sketch the bounded-submission loop." pending = set(); for each row: if len(pending) >= N, done, pending = wait(pending, return_when=FIRST_COMPLETED) and handle each done future (f.result() inside try/except to record failures); then pending.add(pool.submit(push, row)). After the loop, wait for the rest. Memory is O(N), and failures are handled as they happen.
  2. "ProcessPoolExecutor.map has a chunksize argument. Does that fix the memory problem?" No. chunksize groups items per IPC message to reduce overhead, but submission is still eager over the whole input. It helps throughput for tiny tasks, not backpressure.

Scoring: Strong: Knows submission is eager and results are ordered, explains memory and head-of-line effects, and proposes bounded in-flight submission or batching (mentioning buffersize on 3.14), plus idempotency and checkpoints for the backfill. Acceptable: Realises the input is consumed eagerly and suggests chunking, without a bounded-concurrency pattern. Red flag: Blames the threads' stacks or the API client for the memory, or proposes more workers.

SR-TH-CON-03 · Diagnosing a service that hangs under load

Medium · Theory · Senior (5–8 years) · deadlocks, py-spy, faulthandler, locks, thread pools, production debugging · 7 min

Question: A threaded Python service stops responding under load: CPU near zero, health checks time out, no errors in the logs. Restarting fixes it for a few hours. How do you diagnose it, and what are the usual causes?

Answer:

Low CPU plus no progress means everything is waiting. The first job is to see what each thread is waiting on, without restarting (the restart destroys the evidence).

Getting stacks from the live process:

  • py-spy dump --pid <pid> prints the Python stack of every thread, no code changes needed (the container needs SYS_PTRACE). Add --native for threads stuck in C code.
  • faulthandler: faulthandler.register(signal.SIGUSR1, all_threads=True) at startup lets you kill -USR1 a stuck process to dump all stacks to stderr; faulthandler.dump_traceback_later(timeout) can act as a watchdog.
  • An admin endpoint that formats sys._current_frames() works too, as long as the server still has a free thread to answer it.

What to look for in the stacks:

  • Lock-order inversion: thread A holds lock 1 and waits for lock 2, thread B the reverse.
  • Pool self-deadlock: tasks running in a pool submit to the same pool and wait on the result. Under load all workers are waiting for tasks that can't start.
  • Resource-pool exhaustion: every thread blocked in "get connection from pool", because connections leaked (not returned on an error path) or the DB pool is smaller than the thread pool.
  • Calls without timeouts to a dependency that has stopped responding.
  • Holding a lock while doing I/O or calling user callbacks (logging handlers, metrics exporters) that need the same lock.

Preventing recurrences: a global lock order (or one lock), never call out to I/O or callbacks while holding a lock, timeouts on every network call and on lock.acquire(timeout=...) in critical paths, separate pools per dependency, and liveness checks that test real progress (a heartbeat from the worker loop), not just "the HTTP thread answers".

Follow-ups:

  1. "The dump shows every worker thread inside queue.get() and the producer thread is gone. What happened?" The producer died from an unhandled exception. threading prints the traceback to stderr (or calls threading.excepthook), which may not reach your log pipeline, and consumers block forever. Wrap thread targets with error logging, set threading.excepthook to log and exit, and use get(timeout=...) so consumers notice a dead producer.
  2. "Would switching to RLock everywhere fix deadlocks?" Only one specific case: the same thread re-acquiring a lock it already holds. It doesn't help with lock-order inversion between threads, and it can hide re-entrancy bugs where a function is called while the protected state is half-updated.

Scoring: Strong: Captures live stacks with py-spy or faulthandler before restarting, names lock inversion, pool self-deadlock and connection-pool exhaustion with how each looks in a dump, and proposes structural prevention plus progress-based health checks. Acceptable: Suspects a deadlock and knows to get thread dumps, but is vague about tools or causes. Red flag: Adds more logging and restarts on a timer, or increases thread count without finding what's blocked.

SR-TH-CON-04 · Joining a process before draining its multiprocessing.Queue

Medium & Tricky · Theory · Senior (5–8 years) · multiprocessing, Queue, deadlock, pipes, process lifecycle · 6 min

Question: A report generator starts a multiprocessing.Process per region; each child puts its result on a multiprocessing.Queue. The parent calls p.join() on every child and then reads the queue. With small test data it works; with a full month's data the job hangs forever. Why?

Answer:

Trap: "Join first so all children are finished, then read the results." A multiprocessing.Queue is a pipe plus a feeder thread in the putting process. put() returns immediately and the feeder thread writes the pickled data to the pipe in the background. The OS pipe buffer is small (typically 64 KiB on Linux). A child will not exit until its feeder thread has flushed everything into the pipe, and the pipe only drains when someone reads it.

So with a 1 MB result: the child waits for the parent to read, and the parent waits in join() for the child to exit. Deadlock, with no error. Small test results fit in the pipe buffer, so the child exits and the bug stays hidden until data grows. Below, after a 2-second join(timeout=...) the child is still alive; draining first then joining works.

Rules:

  • Drain before join: q.get() the expected number of results (with a timeout), then join().
  • Prefer ProcessPoolExecutor/Pool, which handle result transport and worker lifecycle for you.
  • For large results, don't send data through the pipe at all: write to a file or object storage (Parquet) and send back the path. Pickling a big DataFrame through a pipe costs CPU and memory on both sides.
  • Always use timeouts on get() and join() and check exitcode, so a crashed child (exit code ≠ 0, nothing ever put on the queue) doesn't hang the parent forever.

q.cancel_join_thread() lets the child exit without flushing, which avoids the hang by losing data; it's almost never what you want.

import multiprocessing as mp


def build_report(q):
    q.put(b"x" * 1_000_000)        # ~1 MB result: larger than the OS pipe buffer


if __name__ == "__main__":
    q = mp.Queue()
    p = mp.Process(target=build_report, args=(q,))
    p.start()

    p.join(timeout=2)              # "wait for the worker, then read its result"
    print("worker finished before we read the queue?", not p.is_alive())

    result = q.get()               # drain first...
    p.join()                       # ...then join
    print("got", len(result), "bytes; exit code", p.exitcode)

Output:

worker finished before we read the queue? False
got 1000000 bytes; exit code 0

Follow-ups:

  1. "A child is killed by the OOM killer before putting its result. What does the parent see with the "drain first" pattern?" A q.get() that never returns, unless it has a timeout. Use get(timeout=...), then check each process's exitcode (negative means killed by a signal, -9 for SIGKILL) and treat missing results as failures to retry or report.
  2. "Why does this not happen with queue.Queue between threads?" queue.Queue is an in-memory structure shared by threads in one process: put just appends under a lock, and there's no pipe or feeder thread whose flushing blocks thread exit.

Scoring: Strong: Explains the feeder thread, the pipe buffer and the child refusing to exit until flushed, why small data hides it, and fixes it with drain-before-join, timeouts and exit-code checks, or by sending paths instead of data. Acceptable: Knows to read the queue before joining, but can't explain why. Red flag: Suggests join(timeout) and moving on, or daemon processes, which loses results silently.

SR-TH-CON-05 · Profiling a multi-threaded service - CPU, GIL contention or waiting?

Hard · Theory · Senior (5–8 years) · profiling, py-spy, cProfile, GIL contention, wall time vs CPU time, threads · 8 min

Question: A threaded ingestion service (32 threads) processes 400 events/s, and you need 2,000. Adding threads doesn't help. How do you find out whether it's bound by CPU, by GIL contention, or by waiting on I/O, and why can cProfile mislead you here?

Answer:

Why cProfile misleads: it instruments only the thread where it was enabled (others need threading.setprofile or per-thread profilers), it adds heavy per-call overhead that distorts code with many small calls, and it measures wall time inside functions, so a function waiting for the GIL or a socket looks "slow" just like one burning CPU.

A better sequence:

  1. Compare CPU time with wall time for the process. In top/container metrics, about 100% of one core with 32 threads and flat throughput is the GIL signature: pure-Python work is serialised. Low CPU with low throughput means the threads are waiting (I/O, locks, pools).
  2. Sample with py-spy (no restart, low overhead): py-spy record --pid <pid> --idle gives a flame graph across all threads including waiting ones; --gil samples only the thread holding the GIL, showing exactly which Python code is consuming the interpreter. py-spy top gives a live view. Scalene splits Python vs native vs system time; on 3.12, python -X perf lets Linux perf show Python frames.
  3. Instrument the queues: time waiting for a worker, waiting for a DB/HTTP connection from the pool, and inside the call. That separates "slow dependency" from "starved pool".

How the findings map to fixes:

  • GIL-bound (JSON parsing, pydantic validation, per-event Python logic): use processes (a process per core, or more pods), vectorise in NumPy/pandas/Polars, move hot paths into C-backed libraries (orjson), or batch to cut per-event overhead. More threads just add contention and context switches.
  • I/O-bound: increase concurrency where the bottleneck is (pool sizes, async I/O), batch calls, fix slow queries.
  • Lock-bound: shrink critical sections, shard the lock, or remove shared state.

Check after every change that the bottleneck moved where you expected; on free-threaded builds (3.13t+), the GIL picture changes, but the measurement method stays the same.

Follow-ups:

  1. "Profiles show 40% of time in json.loads and pydantic validation. Options short of rewriting the service?" Validate once at the boundary with pydantic v2's model_validate_json (parses and validates in Rust in one pass), switch to orjson for plain parsing, skip re-validating internal objects, and run N worker processes (gunicorn workers or a process per partition) so parsing uses all cores.
  2. "Why can adding threads to a GIL-bound service make it slower?" Threads contend for the GIL: switch intervals (5 ms by default) force hand-offs, convoy effects make I/O threads wait behind CPU threads to reacquire the GIL, and the OS scheduler adds context switches and cache misses. Throughput stays flat at best, and tail latency rises.

Scoring: Strong: Explains cProfile's thread and wall-time limitations, compares CPU vs wall time, uses py-spy (--idle, --gil) or similar sampling tools, instruments pool and queue waits, and maps each finding to a different fix (processes or vectorisation vs pool sizing vs lock redesign). Acceptable: Suspects the GIL and suggests multiprocessing, with some profiling, but can't distinguish GIL contention from I/O wait methodically. Red flag: Keeps increasing thread count, or relies solely on cProfile output from the main thread.

SR-TH-CON-06 · SIGTERM, threads and graceful shutdown in a container

Hard & Tricky · Theory · Senior (5–8 years) · signals, threading, graceful shutdown, Kubernetes, daemon threads, SIGTERM · 7 min

Question: A worker that consumes jobs in background threads is deployed on Kubernetes. On every rolling deploy, pods take the full 30-second grace period and are SIGKILLed, leaving half-processed jobs. A teammate tries installing the SIGTERM handler inside a worker thread. Explain what's going on and how shutdown should work.

Answer:

Trap: "Signals are delivered to whichever thread is running, so any thread can handle SIGTERM, and stopping the main thread stops the workers." In CPython:

  • Python-level signal handlers always run in the main thread, between bytecodes, and signal.signal() can only be called from the main thread: calling it elsewhere raises ValueError (below).
  • Worker threads aren't interrupted by signals. If the main thread exits, the interpreter waits for all non-daemon threads at shutdown; the process stays alive until Kubernetes sends SIGKILL. Daemon threads are killed abruptly at interpreter exit, mid-job, with no finally blocks run.
  • If Python runs as PID 1 in the container, the kernel ignores signals that have no handler installed, so an unhandled SIGTERM does nothing at all. A shell-form CMD (sh -c "python app.py") makes the shell PID 1, and it usually doesn't forward the signal either.

A clean design:

  1. The main thread installs the SIGTERM (and SIGINT) handler, which only sets a threading.Event (handlers should do almost nothing).
  2. Workers loop with bounded waits (stop.wait(timeout), queue.get(timeout=...), socket timeouts), check the event, finish or hand back the current job, commit offsets/acks, and exit.
  3. The main thread stops accepting new work, joins workers with a deadline shorter than the grace period, flushes logs/metrics, and exits.
  4. Use exec-form CMD ["python", "app.py"] or a tiny init (tini, --init) so signals arrive; set terminationGracePeriodSeconds to cover the longest job, or make jobs resumable.

And make jobs idempotent: a SIGKILL can always happen (OOM, node failure), so graceful shutdown reduces duplicates but can't be the only correctness mechanism.

import signal
import threading

errors = []


def install_handler_from_worker():
    try:
        signal.signal(signal.SIGTERM, lambda *_: None)
    except ValueError as e:
        errors.append(str(e))


t = threading.Thread(target=install_handler_from_worker)
t.start()
t.join()
print(errors[0])

stop = threading.Event()


def worker():
    while not stop.wait(timeout=0.05):   # wake up regularly to check for shutdown
        pass                             # ...process one bounded unit of work...
    print("worker: drained and exiting")


def on_sigterm(signum, frame):           # always runs in the MAIN thread
    print("main: got", signal.Signals(signum).name)
    stop.set()


signal.signal(signal.SIGTERM, on_sigterm)
w = threading.Thread(target=worker)
w.start()
signal.raise_signal(signal.SIGTERM)      # what Kubernetes sends on a rolling deploy
w.join(timeout=5)
print("worker alive after join:", w.is_alive())

Output:

signal only works in main thread of the main interpreter
main: got SIGTERM
worker: drained and exiting
worker alive after join: False

Follow-ups:

  1. "The main thread is blocked in w.join() with no timeout when SIGTERM arrives. Does the handler still run?" On POSIX, lock waits in the main thread are interruptible, so the handler runs. But a main thread stuck inside a long C call that doesn't check for signals (some extension code) delays the handler until the call returns. Keep the main thread free: joins with timeouts in a loop, or signal.pause()/event waits.
  2. "How does this change for a gunicorn or uvicorn deployment?" The server's master process handles SIGTERM and coordinates graceful worker shutdown with its own timeout (--graceful-timeout, --timeout-graceful-shutdown). Your code hooks into lifespan/shutdown events to stop background threads and flush state; background threads you start yourself still need the stop-event pattern.

Scoring: Strong: Knows handlers run only in the main thread and can only be installed there, explains non-daemon vs daemon thread behaviour at exit and the PID 1 / shell-form CMD issue, and designs event-based shutdown with bounded waits, a deadline, and idempotent jobs. Acceptable: Uses a stop event and handles SIGTERM in the main thread, but misses PID 1 or daemon-thread consequences. Red flag: Makes all threads daemon so the process "exits quickly", or relies on SIGKILL as the shutdown path.

SR-TH-CTX-01 · Lifecycle API for a client that owns a connection pool

Easy · Theory · Senior (5–8 years) · context managers, resource management, API design, close · 4 min

Question: You're building LedgerClient, an internal SDK class that holds an HTTP connection pool and a background token-refresh thread. Other teams will use it in scripts, Celery workers and FastAPI apps. How should it expose its lifecycle (creation, cleanup), and why not rely on __del__?

Answer:

Give it an explicit, boring lifecycle and make the context manager a thin layer over it:

  • close() releases everything (pool, thread, file handles). It is idempotent: calling it twice is a no-op, so double cleanup in error paths is safe. After close, methods raise a clear error ("client is closed") instead of failing deep inside the pool.
  • __enter__ returns self; __exit__ calls close() and returns None, so exceptions propagate. Scripts write with LedgerClient(...) as client:.
  • Long-lived owners don't use with around each call. A FastAPI app creates one client at startup and closes it in the lifespan handler; a Celery worker creates it per process (after fork) and closes it on shutdown. Document that the client is meant to be reused, since creating one per request throws away the connection pool.
  • Async variant: if the I/O is async, provide aclose() and __aenter__/__aexit__; don't hide async cleanup inside a sync close().
  • Constructor stays cheap, or acquires resources in a way that cleans up after itself if a later step fails (e.g. using an ExitStack internally), so a half-built client never leaks a thread.

Why not __del__: it runs when the object is garbage-collected, which is not guaranteed to be timely (reference cycles wait for the cyclic GC; other runtimes have no refcounting) or to happen at all at interpreter shutdown, when module globals may already be torn down. Exceptions inside it are only printed, never raised. At most, use __del__ or weakref.finalize as a safety net that emits a ResourceWarning ("LedgerClient was never closed"), the way open() files and asyncio transports do, so leaks show up in tests run with -W error::ResourceWarning.

Follow-ups:

  1. "A team wraps every call in with LedgerClient() as c: c.post(...). What's the cost?" Each request builds a new pool and a new TLS connection (handshake on every call) and starts and stops a thread. Latency and CPU go up, and under load you can exhaust ephemeral ports with TIME_WAIT sockets. The client should be created once and shared.
  2. "Should close() on a client be thread-safe?" If the client is shared across threads, yes: guard the closed flag with a lock, or document that closing must happen after all users have stopped. The common bug is a worker thread using the client while shutdown closes it.
  3. "How do you test that teams' code closes the client?" Emit ResourceWarning from a finalizer on unclosed clients, and run tests with warnings turned into errors (-W error::ResourceWarning or pytest's filterwarnings).

Scoring: Strong: Proposes an idempotent close() with the context manager on top, distinguishes per-use vs long-lived ownership (app lifespan, per worker process), covers async, and explains why __del__ is only a safety net with ResourceWarning. Acceptable: Implements __enter__/__exit__ calling close, but doesn't address long-lived usage or why __del__ is unreliable. Red flag: Relies on __del__ for cleanup, or makes __exit__ swallow exceptions.

SR-TH-CTX-02 · Does __exit__ run when Kubernetes stops the pod?

Easy & Tricky · Theory · Senior (5–8 years) · context managers, signals, SIGTERM, graceful shutdown, finally · 5 min

Question: A worker takes a distributed lease inside with lease(job_id): and releases it in __exit__. After deploys, some jobs stay locked until the lease expires 30 minutes later. "But with guarantees cleanup," says the author. Does it?

Answer:

Trap: "with and finally always run."

They run when the block is left by normal completion, return/break, or a Python exception. They do not run when the process is killed without Python getting control:

  • SIGTERM with the default disposition. Python installs a handler for SIGINT (turned into KeyboardInterrupt) but not for SIGTERM. Kubernetes sends SIGTERM on pod shutdown; by default the process just dies, so no finally, no __exit__, no atexit.
  • SIGKILL (after the grace period, or the OOM killer), os._exit(), a segfault in a C extension, or the machine going away. Nothing can run.
  • Daemon threads are stopped abruptly at interpreter exit, mid-with.

Fixes, in layers:

  • Install a SIGTERM handler that turns the signal into an exception or a "stop" flag (signal.signal(SIGTERM, lambda s, f: sys.exit(128 + s)), or set an Event the worker loop checks), so the current block unwinds and __exit__ runs. Many frameworks (gunicorn, uvicorn, Celery) already do this; check that the worker you run actually gets the signal (PID 1 in a container, exec in the entrypoint).
  • Size terminationGracePeriodSeconds to cover the longest cleanup, and stop taking new work as soon as the signal arrives.
  • Design for the uncatchable case anyway: leases with a TTL and heartbeat, idempotent jobs that can be retried, and reconciliation. __exit__ is an optimisation for the happy shutdown, not the correctness guarantee.

The snippet runs the same worker in a child process twice: once with the default SIGTERM disposition, once with a handler.

import subprocess
import sys

CHILD = r'''
import os, signal, sys
if sys.argv[1] == "handle":
    signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(128 + signum))

class Lease:
    def __enter__(self):
        print("lease acquired", flush=True)
    def __exit__(self, *exc):
        print("lease released", flush=True)

with Lease():
    os.kill(os.getpid(), signal.SIGTERM)   # what Kubernetes sends on pod shutdown
    signal.pause()
'''

for mode in ["default", "handle"]:
    p = subprocess.run([sys.executable, "-c", CHILD, mode], capture_output=True, text=True)
    print(mode, "->", p.stdout.splitlines(), "exit", p.returncode)

Output:

default -> ['lease acquired'] exit -15
handle -> ['lease acquired', 'lease released'] exit 143

Follow-ups:

  1. "Why does it matter whether your Python process is PID 1 in the container?" If a shell script is PID 1 and starts Python as a child without exec, the shell receives SIGTERM and typically doesn't forward it, so Python is SIGKILLed after the grace period with no cleanup. Use exec python ... in the entrypoint or a tiny init (tini, docker run --init).
  2. "Is it safe to do the actual cleanup work inside the signal handler?" Keep handlers minimal. Python runs them in the main thread between bytecodes, so they can interrupt code holding a lock or halfway through an update. Raising an exception or setting a flag and letting normal unwinding do the cleanup is safer.
  3. "What's the asyncio equivalent?" loop.add_signal_handler(signal.SIGTERM, ...) to cancel the main task or set an event; cancellation then unwinds async with blocks. asyncio.run already handles SIGINT this way since 3.11, but not SIGTERM.

Scoring: Strong: Knows SIGTERM's default action kills the process without unwinding, lists uncatchable cases (SIGKILL, OOM, os._exit), proposes a SIGTERM handler and grace period, and still designs for crash-safety with TTL leases and idempotency. Acceptable: Knows SIGKILL skips cleanup but assumes SIGTERM behaves like Ctrl-C. Red flag: Insists with always runs, or proposes catching SIGKILL.

SR-TH-CTX-03 · A transaction context manager that nests safely

Medium · Theory · Senior (5–8 years) · context managers, transactions, savepoints, databases, unit of work · 6 min

Question: You're standardising database writes behind with transaction(conn):. Service code sometimes calls a helper that also opens with transaction(conn): inside an outer one. What semantics should the context manager have, and how do you handle nesting?

Answer:

Semantics I'd write down and test:

  • Commit only on clean exit; roll back on any exception and re-raise. Catch BaseException for the rollback (so KeyboardInterrupt and asyncio cancellation also roll back), then raise. The context manager never suppresses.
  • Commit failures propagate. COMMIT itself can fail (serialization failure, deferred constraint, lost connection). That error must reach the caller; never log-and-continue, or the caller reports success for data that isn't there.
  • Nesting has an explicit rule. Options:
  • Savepoints: the outermost block is a real transaction; inner blocks become SAVEPOINT / RELEASE / ROLLBACK TO. An inner failure that the caller catches undoes only the inner work. This is what SQLAlchemy's begin_nested() and Django's nested atomic() do.
  • Join the outer transaction: inner blocks do nothing; an inner failure dooms the whole transaction. Simpler, but a caught inner exception leaves the outer one in a state that must still roll back.
  • Forbid nesting: raise if a transaction is already open. Strict, and makes "who owns the transaction" obvious.
  • Transaction state belongs to the connection, not a global. Detect the outer transaction from the connection (conn.in_transaction) or a per-connection/per-task object, never a module-level counter shared across threads or async tasks.
  • Side effects after commit. Publishing events or sending SMS inside the block is wrong: a rollback can't unsend them. Collect them and run them after commit (or use an outbox table written in the same transaction).

The snippet uses SQLite to show savepoint nesting and that an outer BaseException rolls everything back.

import itertools
import sqlite3
from contextlib import contextmanager

_sp_ids = itertools.count(1)

@contextmanager
def transaction(conn):
    """Outermost call: BEGIN/COMMIT/ROLLBACK. Nested call: a SAVEPOINT."""
    if conn.in_transaction:
        sp = f"sp{next(_sp_ids)}"
        begin, commit, rollback = [f"SAVEPOINT {sp}"], [f"RELEASE {sp}"], [f"ROLLBACK TO {sp}", f"RELEASE {sp}"]
    else:
        begin, commit, rollback = ["BEGIN"], ["COMMIT"], ["ROLLBACK"]
    for stmt in begin:
        conn.execute(stmt)
    try:
        yield conn
    except BaseException:          # includes KeyboardInterrupt and task cancellation
        for stmt in rollback:
            conn.execute(stmt)
        raise                      # never swallow
    for stmt in commit:            # a failing COMMIT propagates to the caller
        conn.execute(stmt)

conn = sqlite3.connect(":memory:", isolation_level=None)   # we manage transactions
conn.execute("CREATE TABLE ledger (entry TEXT, paise INTEGER)")

with transaction(conn):
    conn.execute("INSERT INTO ledger VALUES ('order A1', 49900)")
    try:
        with transaction(conn):                     # optional cashback step
            conn.execute("INSERT INTO ledger VALUES ('cashback A1', 5000)")
            raise ValueError("cashback campaign expired")
    except ValueError as exc:
        print("inner rolled back:", exc)
    conn.execute("INSERT INTO ledger VALUES ('fee A1', -998)")
print(conn.execute("SELECT entry, paise FROM ledger").fetchall())

try:
    with transaction(conn):
        conn.execute("INSERT INTO ledger VALUES ('order B7', 1000)")
        raise KeyboardInterrupt
except KeyboardInterrupt:
    print("outer rolled back, rows:", conn.execute("SELECT COUNT(*) FROM ledger").fetchone()[0])

Output:

inner rolled back: cashback campaign expired
[('order A1', 49900), ('fee A1', -998)]
outer rolled back, rows: 2

Follow-ups:

  1. "The ROLLBACK itself raises because the connection dropped. What does the caller see?" The rollback error, with the original exception attached as __context__ ("During handling of the above exception, another exception occurred"). Both are in the traceback, but except ValueError in the caller no longer matches. Some teams catch and log rollback failures so the original exception propagates; either way, the connection must be discarded from the pool, not reused.
  2. "Why is the except BaseException acceptable here when it's usually a red flag?" Because it re-raises unconditionally. Catching BaseException is only harmful when it swallows; doing cleanup and re-raising is exactly what finally would do, with the extra knowledge of failure vs success.
  3. "How would this look with asyncio and an async driver?" An @asynccontextmanager with awaited BEGIN/COMMIT/ROLLBACK. The rollback after cancellation may itself be interrupted, so keep it short, and let the pool discard connections whose state is unknown.

Scoring: Strong: Commits only on success, rolls back on BaseException and re-raises, lets COMMIT errors propagate, chooses and justifies a nesting rule (savepoints, join, or forbid), keeps state per connection, and moves external side effects after commit. Acceptable: Gets commit/rollback/re-raise right but has no answer for nesting, or uses a global depth counter. Red flag: Suppresses exceptions after rolling back, or commits in finally.

SR-TH-CTX-04 · Reusable is not reentrant: sharing one context manager instance

Medium & Tricky · Theory · Senior (5–8 years) · context managers, reentrancy, thread safety, metrics, RLock · 5 min

Question: To avoid allocations, a service creates one module-level db_timer = Timer("db") and every code path does with db_timer: to record query latency. It's a class with __enter__/__exit__, so "it can be used many times". The latency dashboard looks wrong. Why?

Answer:

Trap: "A class-based context manager can be reused, so sharing one instance is fine."

Three different properties get confused:

  • Reusable: can be entered again after the previous with has exited. A class that resets its state in __enter__ is reusable; a @contextmanager object is not.
  • Reentrant: can be entered again while already inside a with on the same object (nesting, recursion). threading.RLock is reentrant; threading.Lock is not (nesting it in one thread deadlocks).
  • Thread-/task-safe: several threads or asyncio tasks can be inside it at once.

Timer stores self.start on the shared instance, so it is reusable but neither reentrant nor thread-safe. A nested with db_timer: (a helper that also times itself) overwrites start, and the outer duration is under-reported. With 32 threads or many concurrent asyncio tasks sharing it, every request's start races with every other's, so the numbers are noise. Nothing raises; the dashboard is simply wrong.

Fixes:

  • Keep per-use state off the shared object: have the shared object return a new span object per with (with db_timer():), or store state in a local stack/ContextVar keyed per task.
  • For locks, decide deliberately: RLock if re-entry from the same thread is legitimate, Lock plus a code structure that never nests if not.
  • Document each shared context manager as "single-use", "reusable" or "reentrant", the way the contextlib docs do.

import itertools

ticks = itertools.count(0, 10)          # fake clock: 0, 10, 20, ... ms
def now():
    return next(ticks)

class Timer:
    def __init__(self, name):
        self.name = name
    def __enter__(self):
        self.start = now()
        return self
    def __exit__(self, *exc):
        print(f"{self.name}: {now() - self.start} ms")

db_timer = Timer("db")                  # one shared instance

with db_timer:                          # outer: whole query batch
    with db_timer:                      # inner: a helper reuses the same instance
        pass

class SpanFactory:                      # shared object, per-use state
    def __init__(self, name):
        self.name = name
    def __call__(self):
        return _Span(self.name)

class _Span:
    def __init__(self, name):
        self.name = name
    def __enter__(self):
        self.start = now()
    def __exit__(self, *exc):
        print(f"{self.name}: {now() - self.start} ms")

db = SpanFactory("db")
with db():
    with db():
        pass

Output:

db: 10 ms
db: 20 ms
db: 10 ms
db: 30 ms

Follow-ups:

  1. "Walk through why the first outer measurement is 20 ms instead of 30." Outer __enter__ sets start=0; inner __enter__ overwrites it with 10; inner exit reads 20 and reports 10; outer exit reads 30 and computes 30 - 10 = 20. The outer span lost the time before the inner block started.
  2. "Is with lock: on a threading.Lock from inside a function that the same thread calls recursively safe?" No, the second acquire blocks forever: a plain Lock has no owner and doesn't know the same thread already holds it. Use RLock if re-entry is by design, but treat the need for it as a smell worth a second look.
  3. "Which standard library context managers are reentrant?" RLock, contextlib.suppress, redirect_stdout/redirect_stderr (reentrant but not thread-safe, since they patch sys.stdout); ExitStack is reusable. open() file objects and @contextmanager objects are single-use.

Scoring: Strong: Distinguishes reusable, reentrant and thread-safe; explains the overwritten start under nesting and concurrency; fixes with per-use span objects or task-local state; connects it to Lock vs RLock. Acceptable: Identifies the race between threads but not the nesting problem, or vice versa. Red flag: Thinks a class-based context manager is automatically safe to share, or blames clock precision.

SR-TH-CTX-05 · Context managers that change process-wide state in a concurrent service

Hard · Theory · Senior (5–8 years) · context managers, thread safety, contextlib.chdir, decimal, mock.patch, contextvars · 7 min

Question: A report endpoint in a threaded web service does with contextlib.chdir(tmp_dir): to write files with relative paths, and with decimal.localcontext(prec=4): for a summary. In a code review, how do you judge which "temporary change" context managers are safe in a concurrent service?

Answer:

The question for each one is where the state it changes lives:

  • Process-wide state: unsafe with threads. contextlib.chdir (the working directory belongs to the process), os.environ patches, os.umask, sys.stdout redirection (redirect_stdout), logging level changes, locale.setlocale, signal handlers, warnings.catch_warnings (it swaps the module-level filter list), and unittest.mock.patch (it replaces a module attribute everyone sees). While one request is inside the block, every other thread sees the change: another request writes its files into the wrong directory, or logs vanish. The docs for chdir and redirect_stdout say so explicitly.
  • Thread-local or context-local state: safe. decimal.localcontext() changes the current context, which is per thread and per asyncio task (decimal uses a ContextVar). Same for anything built on contextvars with token = var.set(...) / var.reset(token).
  • Object-local state: safe if the object isn't shared, e.g. a Session or a client created per request.

What I'd ask for instead:

  • Pass explicit paths (tmp_dir / "summary.csv") and explicit settings (a Decimal context object, a logger argument) rather than changing ambient state.
  • In tests, mock.patch is fine because tests are usually single-threaded; in production code it is a red flag.
  • If a library forces process-wide state (an old C library reading the cwd or env), run that work in a subprocess or a dedicated worker process, not a thread.
  • For asyncio, ContextVar is the right tool for per-request ambient values; module globals changed inside async with blocks leak between interleaved tasks exactly as they do between threads.

The snippet shows two threads: one inside both context managers, the other observing.

import contextlib
import decimal
import os
import tempfile
import threading

entered = threading.Event()
done = threading.Event()
seen = {}

def report_job(tmp):
    with contextlib.chdir(tmp), decimal.localcontext(prec=4):
        entered.set()
        done.wait()

def other_request(tmp):
    entered.wait()
    seen["cwd_changed"] = os.getcwd() == os.path.realpath(tmp)
    seen["prec"] = decimal.getcontext().prec
    done.set()

with tempfile.TemporaryDirectory() as tmp:
    a = threading.Thread(target=report_job, args=(tmp,))
    b = threading.Thread(target=other_request, args=(tmp,))
    a.start(); b.start(); a.join(); b.join()
print("other thread sees chdir:", seen["cwd_changed"])
print("other thread sees localcontext prec:", seen["prec"])

Output:

other thread sees chdir: True
other thread sees localcontext prec: 28

Follow-ups:

  1. "Is contextlib.chdir safe in an asyncio service with a single thread?" Only if there is no await inside the block. Any await lets other tasks run with the changed directory, and code that offloads to asyncio.to_thread runs in threads that see it too. Explicit paths avoid the question entirely.
  2. "Why is ContextVar better than threading.local for per-request state in async code?" threading.local is per thread, but many asyncio tasks share one thread, so values leak between requests. Each asyncio task runs in a copy of the context, so a ContextVar set in one request's task isn't visible to another's.
  3. "A test suite that passes serially fails under pytest -n 8 or threaded tests. What do you look for?" Tests or fixtures that change process-wide state (cwd, env, module attributes via patch, logging config) without restoring it, or that assume no other test runs concurrently in the same process. With xdist they are separate processes, so look also for shared files, ports and databases.

Scoring: Strong: Classifies by where the state lives (process, thread/context, object), names concrete unsafe ones (chdir, environ, redirect_stdout, mock.patch, logging) and safe ones (decimal localcontext, ContextVar), and recommends explicit parameters or subprocess isolation. Acceptable: Knows chdir is process-wide and unsafe but can't say why localcontext is safe. Red flag: Assumes anything restored by a with block is safe because "it's undone at the end".

SR-TH-CTX-06 · A @contextmanager that logs the error and accidentally swallows it

Hard & Tricky · Theory · Senior (5–8 years) · context managers, contextlib.contextmanager, exception suppression, code review · 6 min

Question: A shared db_session() helper is written with @contextmanager: it yields inside try, commits after the yield, and in except Exception as exc: logs "rollback" and rolls back. Refund creation sometimes fails later with UnboundLocalError in a line after the with block. How are these connected?

Answer:

Trap: "The except block only logs; the exception still propagates like it would from a normal function."

With @contextmanager, an exception raised in the with body is thrown into the generator at the yield. What the generator does next decides the outcome:

  • If it re-raises (or lets the exception escape), __exit__ propagates it.
  • If it catches it and finishes normally, __exit__ returns True, which means suppress. Execution continues after the with statement as if the block had succeeded.

So the logging except silently converts every failure into success. In the refund code the with body raised before assigning refund_id; the function carried on past the block and failed on return refund_id with UnboundLocalError, far from the real cause. In variants without a later reference, the function returns normally: the API answers 200 for a refund that was rolled back. It also swallows errors from code that just happens to be inside the block, like a typo raising NameError.

Rules for generator-based context managers:

  • yield inside try; cleanup that must always happen goes in finally (without it, an exception in the body skips the cleanup after the yield).
  • In except, do the rollback/logging and then raise. Suppression must be deliberate and narrow (contextlib.suppress(SpecificError)).
  • Yield exactly once; yielding again after an exception gives RuntimeError: generator didn't stop after throw().
  • Test the failure path: assert the exception reaches the caller and the rollback ran.

from contextlib import contextmanager

@contextmanager
def db_session(name):
    print(f"begin {name}")
    try:
        yield name
        print(f"commit {name}")
    except Exception as exc:
        print(f"rollback {name}: {exc!r}")      # logged... and swallowed

def create_refund(order_id, amount):
    with db_session("refunds"):
        if amount <= 0:
            raise ValueError("amount must be positive")
        refund_id = f"RF-{order_id}"
    return refund_id

try:
    print(create_refund("A1", 0))
except Exception as exc:
    print(type(exc).__name__, exc)

Output:

begin refunds
rollback refunds: ValueError('amount must be positive')
UnboundLocalError cannot access local variable 'refund_id' where it is not associated with a value

Follow-ups:

  1. "What's the one-line fix, and what does the caller then see?" Add raise at the end of the except block. The caller gets the original ValueError with its full traceback, after the rollback has run.
  2. "Is there an equivalent mistake in a class-based context manager?" Yes: __exit__ returning something truthy, often by accident, e.g. return self._rollback() where the helper returns True. Any truthy return value suppresses. __exit__ should return None unless suppression is the purpose.
  3. "How would you catch this in review or CI rather than in production?" A unit test that raises inside the block and asserts with pytest.raises that it propagates; and a review rule that every except inside a @contextmanager generator ends with raise or carries a comment explaining the suppression.

Scoring: Strong: Explains the throw-into-generator mechanism and that finishing normally means suppression, traces the UnboundLocalError back to it, and states the rules (try/finally, re-raise, single yield) plus a failure-path test. Acceptable: Knows the missing raise swallows the error but can't explain why the generator's normal completion suppresses. Red flag: Fixes the UnboundLocalError by initialising refund_id = None before the block.

SR-TH-DE-01 · What goes into a data contract, and where it's enforced

Easy · Theory · Senior (5–8 years) · data contracts, schema evolution, ownership, data quality, schema registry · 6 min

Question: Your team's payments_settled table feeds finance reporting and three ML models. Twice this quarter an upstream change broke consumers silently. You propose a data contract. What does it contain, and how is it enforced?

Answer:

A data contract is a versioned, producer-owned agreement about a dataset or event stream, written as code, so a breaking change is caught before it ships rather than discovered in a finance report.

What it contains:

  • Schema: column names, types, nullability, and keys (payment_id unique, merchant_id not null).
  • Semantics: units and conventions that types can't express: amount_paise is an integer in paise, settled_at is UTC, what each status value means, whether refunds appear as negative rows or a separate table.
  • Quality expectations: uniqueness, allowed ranges, referential checks, expected daily volume band.
  • Service levels: freshness (e.g. "yesterday's IST business day is complete by 06:00 IST"), how late data and corrections are handled.
  • Ownership: producing team, on-call channel, consumers registered against it.
  • Evolution rules: which changes are compatible (adding a nullable column) and which are breaking (renaming, changing type or units, tightening nullability, changing the meaning of a value), and the process for breaking ones: a new version, a migration window during which both are published, and a deprecation date.
  • Classification: which fields are PII and their retention.

Where it's enforced (all three, not one):

  1. Producer CI: the contract lives in the producer's repo (a Pydantic model, JSON Schema, Avro/Protobuf, or a YAML spec); a check diffs the proposed schema against the published one and fails the build on a breaking change without a version bump.
  2. At publish time: a schema registry with a compatibility mode for events, or validation in the pipeline before a partition is made visible. Bad records go to a quarantine table with an alert, rather than either failing silently or blocking all data.
  3. On consumption: consumers validate the parts they depend on, so they fail loudly and early.

The organisational part matters as much as tooling: the producer owns the contract and can't change it unilaterally; consumers get a deprecation window.

Follow-ups:

  1. "The upstream team is a vendor's SFTP drop you can't make sign anything. Does a contract still help?" Yes, as a consumer-side contract: you write down what you rely on, validate every file against it on arrival, and quarantine plus alert on violations before loading. It turns silent corruption into a visible incident with a clear owner on your side, and gives you a concrete document for escalations with the vendor.
  2. "Is adding a new value to the status enum a compatible change?" Not automatically. It's additive for the schema, but consumers with exhaustive logic (a CASE mapping each status to a ledger bucket, a strict enum in a Pydantic model) will mis-handle or reject it. Treat new enum values as needing consumer notice, and design consumers with an explicit 'unknown' path.

Scoring: Strong: Covers schema plus semantics, quality, freshness, ownership and evolution rules; enforces in producer CI, at publish with quarantine, and at consumption; treats ownership and deprecation as part of the contract. Acceptable: Describes a schema with types and nullability and validating it in the pipeline, but misses semantics or the evolution process. Red flag: Equates a data contract with "a schema document on the wiki", or enforces only on the consumer side after the data has been loaded.

SR-TH-DE-02 · Daily revenue partitioned by the UTC date for an IST business

Easy & Tricky · Theory · Senior (5–8 years) · time zones, partitioning, business day, datetime, reporting correctness · 5 min

Question: Orders are stored with aware UTC timestamps, and the daily revenue job partitions by ts.date(). Finance in Mumbai says the numbers for "1 April" don't match the payment gateway's report. The data engineer says the timestamps are correct UTC, so the job is correct. Who's right?

Answer:

Trap: "Storing UTC is best practice, so grouping by the UTC date must be correct."

Storing UTC is right; deriving the business day from it in UTC is the bug. The business, the gateway's settlement report and finance all define "1 April" as midnight to midnight IST, which is 18:30 UTC on 31 March to 18:30 UTC on 1 April. Every order between 00:00 and 05:30 IST lands on the previous UTC date, so each "day" is shifted by 5.5 hours: about 23% of the clock, and in practice the late-night share of orders. In the snippet, the two early-morning IST orders on 1 April are counted on 31 March, and the 01:35 IST order on 2 April is counted on 1 April.

How to make it right, as a pipeline standard:

  • Store instants in UTC; derive dates in the business time zone, explicitly: ts.astimezone(ZoneInfo("Asia/Kolkata")).date(), or in SQL (ts AT TIME ZONE 'Asia/Kolkata')::date. Name the column business_date_ist so nobody guesses.
  • Partition and filter on that derived date, or translate the IST day into a UTC range for pruning ([18:30Z previous day, 18:30Z)), as the last line shows.
  • Month and financial-year boundaries inherit the same bug: the 31 March / 1 April boundary is the Indian financial year end, where a 5.5-hour shift moves revenue between years.
  • For multi-country businesses, the business day depends on the market; put the time zone in the data contract.
  • Reconcile against the external report daily so a shift shows up as a systematic, explainable difference.

IST has no daylight saving, so a fixed +05:30 offset is exact here; for zones with DST use zoneinfo, never a fixed offset.

from collections import Counter
from datetime import datetime, timedelta, timezone

IST = timezone(timedelta(hours=5, minutes=30))

# Order timestamps as stored by the app: aware UTC.
orders_utc = [
    datetime(2026, 3, 31, 17, 45, tzinfo=timezone.utc),   # 23:15 IST, 31 Mar
    datetime(2026, 3, 31, 19, 10, tzinfo=timezone.utc),   # 00:40 IST, 1 Apr
    datetime(2026, 3, 31, 22, 30, tzinfo=timezone.utc),   # 04:00 IST, 1 Apr
    datetime(2026, 4, 1, 9, 0, tzinfo=timezone.utc),      # 14:30 IST, 1 Apr
    datetime(2026, 4, 1, 20, 5, tzinfo=timezone.utc),     # 01:35 IST, 2 Apr
]

by_utc_date = Counter(ts.date().isoformat() for ts in orders_utc)
by_ist_date = Counter(ts.astimezone(IST).date().isoformat() for ts in orders_utc)
print("UTC days:", dict(sorted(by_utc_date.items())))
print("IST days:", dict(sorted(by_ist_date.items())))

# Correct partition filter for the IST business day 1 April, expressed in UTC:
start = datetime(2026, 4, 1, tzinfo=IST).astimezone(timezone.utc)
print("1 Apr IST = UTC", start.isoformat(), "to", (start + timedelta(days=1)).isoformat())

Output:

UTC days: {'2026-03-31': 3, '2026-04-01': 2}
IST days: {'2026-03-31': 1, '2026-04-01': 3, '2026-04-02': 1}
1 Apr IST = UTC 2026-03-31T18:30:00+00:00 to 2026-04-01T18:30:00+00:00

Follow-ups:

  1. "The raw files land in folders named by UTC date (dt=2026-04-01/). How do you build the IST daily table efficiently?" An IST day spans two UTC folders, so read both dt=2026-03-31 and dt=2026-04-01, filter on the UTC range, and write the IST partition. It also means the IST day can't be finalised until the later UTC folder (plus the late-data allowance) is complete, which goes into the freshness SLA.
  2. "Why not just store IST timestamps everywhere, since the business is in India?" Naive local timestamps lose the offset and break as soon as there's a second region, a cloud service that logs UTC, or a vendor in another zone. Store aware UTC instants, keep the business zone as metadata, and convert at the boundaries: business-date derivation and display.

Scoring: Strong: Separates storing instants (UTC) from deriving the business date (IST); quantifies the 5.5-hour shift; translates the IST day into a UTC range for partition pruning; raises the financial-year boundary and reconciliation. Acceptable: Spots the time-zone mismatch and fixes the grouping, but doesn't think about partition layout or month and year boundaries. Red flag: Says the UTC numbers are correct and finance should adjust, or "fixes" it by storing naive IST timestamps.

SR-TH-DE-03 · Incremental extraction with an updated_at high-water mark

Medium · Theory · Senior (5–8 years) · incremental loads, change data capture, watermarks, transactions, idempotency · 7 min

Question: An hourly job extracts changed rows from the orders database with SELECT * FROM orders WHERE updated_at > :last_max and stores the new max(updated_at) for the next run. Months later, the warehouse is missing a few hundred orders and still shows some cancelled orders as live. What went wrong, and how would you design it?

Answer:

A high-water mark on updated_at looks simple, but it has several ways to skip rows permanently:

  1. Commit order isn't timestamp order. updated_at is stamped inside the transaction (in Postgres, now() is even the transaction's start time), but the row becomes visible only at commit. A long transaction stamps a row at 10:00:05 but commits at 10:00:40. An extract running at 10:00:30 can't see that row yet, but it does see other rows stamped up to 10:00:29 and stores max = 10:00:29. The next run asks for > 10:00:29, so the 10:00:05 row is never read.
  2. Ties at the boundary. With >, a row committed later with exactly the stored max timestamp is skipped.
  3. Writers that don't bump updated_at: a bulk fix-up script, a migration, a trigger that someone forgot, an ORM update() that bypasses the model hook.
  4. Hard deletes are invisible: a query can't return a row that no longer exists, which is how "cancelled" orders that were deleted stay live downstream.
  5. App-server clock skew, if the application rather than the database sets the timestamp.

Design I'd use:

  • Lookback overlap: extract updated_at > last_max - overlap where the overlap exceeds the longest realistic transaction (say 15 minutes), and an upper bound updated_at <= now() - safety_lag, so rows still in flight are picked up next time.
  • Idempotent load: because of the overlap, rows are read more than once, so upsert on the primary key keeping the latest version (ON CONFLICT ... DO UPDATE ... WHERE excluded.updated_at >= target.updated_at).
  • Advance the watermark only after the load commits, and store it with the batch ID for traceability.
  • Deletes: soft deletes (deleted_at) in the source, or, better for a critical table, log-based change data capture (Debezium reading the Postgres WAL or MySQL binlog). CDC sees every insert, update and delete in commit order, doesn't depend on application discipline, and doesn't load the primary with range scans.
  • A periodic full reconciliation (per-day counts and key checksums against the source) to catch anything the incremental path missed.

Follow-ups:

  1. "CDC sounds strictly better. Why not always use it?" It adds infrastructure (Kafka/Debezium or a managed service), needs replication privileges and a replication slot that can bloat the WAL if the consumer stalls, and its events reflect physical row changes, so consumers must handle ordering, schema changes and initial snapshots. For a small, non-critical table a careful watermark with overlap is often enough.
  2. "How do you pick the overlap window, and how do you know it's big enough?" From data: the maximum transaction duration in the source (pg_stat_activity history, application timeouts) plus clock skew, with margin. Then verify with the reconciliation job: if it ever finds rows that the incremental path missed, the window is too small or a writer isn't stamping rows.

Scoring: Strong: Explains commit-order vs timestamp-order skipping, boundary ties, missing bumps and deletes; designs an overlap with upper bound, idempotent upsert, watermark-after-commit, and CDC or soft deletes, plus reconciliation. Acceptable: Mentions deletes and suggests >= with deduplication, but doesn't see the in-flight transaction problem. Red flag: Says the query is correct and the missing rows must be a load failure, or proposes nightly full reloads of a large table as the only fix.

SR-TH-DE-04 · Row counts and totals match, and the load is still wrong

Medium & Tricky · Theory · Senior (5–8 years) · reconciliation, data quality, duplicates, pandas merge, observability · 6 min

Question: After each load of UPI transactions, the pipeline checks that the source and target row counts match, and also that the amount totals match. Both checks pass for a day where finance later finds a missing transaction. How is that possible, and what reconciliation would you put in place?

Answer:

Trap: "If both the count and the sum match, the data must be identical."

Aggregate checks are lossy: different errors can cancel out. A retried batch loads T2 twice while a dropped batch loses T4; if the two rows happen to have the same amount (₹499 is a very common price), both the count and the sum are unchanged. In real volumes, where many transactions share common amounts and duplicates and drops come from the same retry logic, this isn't a coincidence to dismiss. The snippet shows identical counts and totals, while a key-level comparison immediately finds one duplicated and one missing ID.

Reconciliation design, cheapest first:

  1. Key uniqueness in the target (GROUP BY txn_id HAVING count(*) > 1 returns nothing). Catches duplicates on its own, and should be a constraint or a merge key where the store supports one.
  2. Key-set comparison between source and target for the partition: anti-joins both ways (missing, unexpected). For millions of rows, compare per-bucket digests first (hash IDs into 1,024 buckets, compare count and an order-independent checksum per bucket, such as a sum of hashes mod 2^64), then drill into only the mismatched buckets.
  3. Value-level checks on the matched keys for critical columns (amount, status), again via per-row hashes rather than column totals.
  4. Against an external source of truth where one exists: the bank or gateway settlement file.

Operationally: run it as a gate that blocks publishing the partition, write mismatches to a table (not just a log line), alert with the counts, and make the load idempotent (upsert on txn_id) so the fix for a failure is a rerun.

import pandas as pd

source = pd.DataFrame({"txn_id": ["T1", "T2", "T3", "T4"],
                       "amount_paise": [125_000, 49_900, 310_000, 49_900]})
# A retried batch loaded T2 twice; a dropped batch lost T4 (same ₹499 amount).
target = pd.DataFrame({"txn_id": ["T1", "T2", "T2", "T3"],
                       "amount_paise": [125_000, 49_900, 49_900, 310_000]})

print("row counts:", len(source), len(target))
print("sums:      ", source.amount_paise.sum(), target.amount_paise.sum())

per_key = target.groupby("txn_id", as_index=False).agg(copies=("txn_id", "size"))
recon = source.merge(per_key, on="txn_id", how="outer", indicator=True)
print(recon[(recon._merge != "both") | (recon.copies != 1)].to_string(index=False))

Output:

row counts: 4 4
sums:       534800 534800
txn_id  amount_paise  copies    _merge
    T2         49900     2.0      both
    T4         49900     NaN left_only

Follow-ups:

  1. "Why is copies printed as 2.0 and NaN rather than integers?" The outer merge introduces a missing value for T4, and the default int64 dtype can't hold NaN, so pandas upcasts the column to float64. In a real check, use the nullable Int64 dtype or fillna(0) before comparing, and don't let float conversion near IDs or amounts.
  2. "Source and target are in different systems and too big to pull into one pandas process. How do you compare keys?" Push the work to each side: each system computes per-bucket (count, checksum) on its own data with SQL, you compare the small summaries in Python, and only mismatched buckets are fetched and compared row by row. That's the same idea as Merkle-tree or rsync-style reconciliation.

Scoring: Strong: Explains how errors cancel in aggregates; proposes uniqueness, two-way key comparison and bucketed checksums at scale; makes reconciliation a publish gate with an idempotent rerun path. Acceptable: Says a key-level comparison is needed and writes the anti-join, but has no plan for large volumes or for acting on mismatches. Red flag: Adds more aggregate checks (averages, min/max) as the fix, or dismisses the case as a one-in-a-million coincidence.

SR-TH-DE-05 · Choosing an execution engine for a 200 GB daily job

Hard · Theory · Senior (5–8 years) · pandas, DuckDB, Polars, Spark, multiprocessing, Parquet, capacity planning · 8 min

Question: A new pipeline must process about 200 GB of gzipped JSON clickstream per day into daily aggregates and a cleaned table. One engineer wants pandas on a big VM, another wants Spark on a cluster. How do you decide, and what would you choose?

Answer:

I'd decide from the shape of the work and the data after pruning, not from the raw size, and I'd prototype on one day's sample before committing.

Questions that drive the choice:

  • Effective size: 200 GB of gzipped JSON is perhaps 1–2 TB parsed in memory, but if the job needs 8 of 60 fields and lands as Parquet, the working set may be 20–40 GB columnar.
  • Operations: per-record cleaning and filtering is embarrassingly parallel; wide joins with large tables or high-cardinality group-bys need shuffles.
  • Latency and growth: one daily batch with a 3-hour window, or hourly? Growing 2× a year?
  • Team and platform: is there an existing Spark platform with on-call, or would we be operating a cluster for one job?

Options and where each fits:

Engine Good for Limits
pandas Small to medium data, rich ecosystem Single-threaded for most operations, whole frame in memory (often 5–10× the CSV size), object dtype strings are expensive; fine per chunk, poor for whole-day joins
DuckDB / Polars (single node) Columnar, multi-threaded, vectorised, out-of-core spilling, predicate and projection pushdown on Parquet; hundreds of GB on one large VM One machine's CPU and disk; no built-in fault tolerance mid-job
Python multiprocessing / a task queue over files The parse stage: thousands of independent gzipped files, CPU-bound JSON parsing, each worker writes one Parquet file You own retries, partial failure, and idempotent outputs
Spark Data that doesn't fit one node's disk/memory budget, large shuffles and joins, existing platform, fault tolerance per task Cluster cost and ops, JVM to Python serialisation for Python UDFs (use built-in functions or Arrow-based pandas UDFs), slow start; overkill for tens of GB

What I'd likely choose here: a two-stage design. Stage 1 converts raw JSON into date-partitioned Parquet with only the needed columns, in parallel per file (a process pool on one VM, or managed batch jobs), writing each output atomically so reruns are idempotent. Stage 2 runs the aggregates with DuckDB or Polars over that Parquet. That's cheaper and simpler to operate than a cluster, and the Parquet layout is portable to Spark if growth or joins later demand it. I'd write down the trigger for moving (e.g. "stage 2 exceeds 60% of its time window or 70% of VM memory") so the decision is revisited with data.

Whatever the engine: profile the parse stage first (JSON parsing is usually the bottleneck; orjson or Arrow's JSON reader can be several times faster than json), and track runtime and peak memory per run as metrics.

Follow-ups:

  1. "Why does a Python UDF in PySpark often make a job several times slower?" Each row is serialised from the JVM to a Python worker process and back, and evaluated by the interpreter one row at a time; it also blocks Catalyst optimisations. Built-in Spark SQL functions run in the JVM with code generation. If Python is unavoidable, use pandas/Arrow UDFs, which move columnar batches.
  2. "The process-pool stage sometimes dies with the OOM killer at 3 a.m. How do you make it robust?" Bound memory per worker (stream-decompress and parse in chunks rather than reading a whole file), size the pool to memory rather than CPU count, set maxtasksperchild to recycle workers that fragment memory, write outputs atomically with a per-file success marker, and make the driver skip files already done so a rerun resumes rather than restarts.

Scoring: Strong: Reasons from effective size after pruning and the operations required; knows the strengths and costs of pandas, DuckDB/Polars, multiprocessing and Spark; proposes a staged design with Parquet, idempotent outputs and an explicit trigger for scaling out. Acceptable: Makes a defensible choice with some reasoning about memory and parallelism, but doesn't consider columnar formats or operating costs. Red flag: Picks Spark "because it's big data" or pandas "because the team knows it" without considering memory, parallelism or growth.

SR-TH-DE-06 · Partitioning an events lake by user_id

Hard & Tricky · Theory · Senior (5–8 years) · partitioning, small files, Parquet, data layout, table formats, compaction · 7 min

Question: Most analyst queries on the events lake filter by user_id, so an engineer proposes partitioning the Parquet table by user_id (40 million users), written by a streaming job every minute. Good idea?

Answer:

Trap: "Partition by the column you filter on most."

Partitioning by a high-cardinality column creates one directory per value, and a streaming writer creates a new file per partition per micro-batch. With 40 million users and a write every minute, you get millions of tiny files a day. Consequences:

  • Metadata dominates: query planning lists millions of objects in object storage (slow, and billed per request) before reading anything.
  • Per-file overhead: each Parquet file has a footer to read and its own open cost; a few KB of data per file gives terrible compression and no useful row-group statistics.
  • Writers suffer too: each micro-batch opens a file per active user; memory and output-stream counts explode.
  • Any query not filtering by user (daily active users, revenue by city) scans all those tiny files.

What I'd do instead:

  • Partition by a low-cardinality column that matches common filters and the data lifecycle: usually event_date (in the business time zone), occasionally plus a small dimension like country. Partitions should hold hundreds of MB to a few GB.
  • Make user lookups fast within the date partitions: sort or cluster files by user_id so Parquet min/max statistics let readers skip most row groups; or use bucketing or a table format's clustering (Iceberg sort orders, Delta Z-ordering, liquid clustering).
  • Compact the streaming output: write small files for freshness, and run a compaction job that rewrites each partition into files of roughly 128 MB to 1 GB. Table formats (Iceberg, Delta, Hudi) make that rewrite atomic for readers and track files in metadata, so planning doesn't list directories.
  • Question the access pattern: if the real need is "show me this user's last 100 events" with low latency, that's a point lookup, and it belongs in a key-value or OLTP store keyed by user, fed from the same stream, not in the analytical lake.

Follow-ups:

  1. "How would you detect a small-files problem in an existing table before users complain?" Track file count and average file size per partition as table metrics (from the table format's metadata or an inventory of the bucket), plus query planning time. Alert when average file size drops below a threshold such as 32 MB or file count per partition grows unexpectedly.
  2. "Daily partitions are now 3 TB each and some queries need just an hour. Partition by hour?" Possibly, if hourly partitions stay large (3 TB / 24 is about 125 GB, which is fine) and many queries filter by hour. Alternatively, keep daily partitions and sort by event time within them so statistics prune row groups. With Iceberg, hidden partitioning lets you change the partition spec without rewriting old data or changing queries.

Scoring: Strong: Explains directory and file explosion from high-cardinality partitioning plus streaming writes; partitions by date, clusters or sorts by user within partitions, compacts, and mentions table formats; questions whether point lookups belong in the lake. Acceptable: Knows high-cardinality partitioning creates too many small files and suggests date partitioning, without a plan for fast per-user queries or compaction. Red flag: Agrees with partitioning by user_id, or proposes partitioning by both date and user_id.

SR-TH-DEC-01 · Review checklist for a decorator shared across a team

Easy · Theory · Senior (5–8 years) · decorators, functools.wraps, inspect.signature, code review, library design · 4 min

Question: A teammate opens a PR adding @audit to the team's shared utilities package; it will be applied to service functions and FastAPI endpoints across several repos. What do you check before approving it?

Answer:

A shared decorator becomes part of every function it touches, so it has to be transparent: callers, tools and frameworks should not notice it except for the behaviour it adds. My checklist:

  • Metadata and signature. @functools.wraps(func) so __name__, __qualname__, __doc__, __module__ and __wrapped__ carry over. inspect.signature() follows __wrapped__, and frameworks depend on that: FastAPI builds request parameters from the signature, pytest reads fixture names from it, and pickling (multiprocessing, Celery) looks functions up by __qualname__. Without wraps, an endpoint's signature becomes (*args, **kwargs).
  • Typing. Annotate it with ParamSpec (def audit[**P, R](func: Callable[P, R]) -> Callable[P, R]) so call sites stay type-checked. A decorator typed as Callable[..., Any] silently turns off checking for every function it decorates.
  • Exceptions pass through unchanged. No swallowing, no re-wrapping in a generic error, and no changed return value on failure. Audit logging must not be able to break the call: a failing log write is caught and counted, not raised into the business path.
  • Function kinds it supports. Sync, async def, generators and methods each need checking, or the decorator should reject the kinds it doesn't support at decoration time. A sync wrapper around a coroutine function "audits" the creation of a coroutine, not its execution.
  • Cost and side effects. Per-call overhead matters on hot paths; nothing heavy at import time; no unbounded global state (lists of calls, caches keyed by arguments).
  • Testability. The original is reachable via __wrapped__, behaviour can be configured at call time (not frozen at import), and the decorator has its own tests on a dummy function.

The code shows the signature difference that a framework actually sees.

import functools
import inspect

def audit_plain(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

def audit(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

def refund(order_id: str, amount_paise: int, *, reason: str = "customer") -> str:
    """Refund an order (amount in paise)."""
    return f"RF-{order_id}"

for deco in (audit_plain, audit):
    f = deco(refund)
    print(f.__name__, inspect.signature(f), "|", f.__doc__)
print(audit(refund).__wrapped__ is refund)

Output:

wrapper (*args, **kwargs) | None
refund (order_id: str, amount_paise: int, *, reason: str = 'customer') -> str | Refund an order (amount in paise).
True

Follow-ups:

  1. "FastAPI endpoints decorated without wraps start returning 422 errors for every request. Why?" FastAPI reads inspect.signature(endpoint) to decide which query, path and body parameters to expect. Without wraps it sees (*args, **kwargs), so it expects query parameters literally named args and kwargs and never passes order_id. The real parameters are invisible to it.
  2. "Does functools.wraps make the wrapper a perfect copy of the original?" No. It copies a fixed set of attributes and updates __dict__, and sets __wrapped__. isinstance checks, inspect.iscoroutinefunction (unless the wrapper is itself async def), identity comparisons and the actual code object are still the wrapper's.
  3. "Where should a decorator like this not be used?" Where the cross-cutting behaviour needs data from inside the function (use explicit calls), or where the behaviour must differ per call site. Decorators are good for uniform policy (auth, metrics, audit); they hide control flow, so business logic does not belong in them.

Scoring: Strong: Covers transparency (wraps, signature, __wrapped__), typing with ParamSpec, exceptions passing through untouched, sync/async/generator coverage, and testability; connects at least one of them to a real framework failure. Acceptable: Mentions functools.wraps and exception transparency but not typing or async/generator coverage. Red flag: Only checks that "it works on one function", or approves a decorator that catches all exceptions and logs them.

SR-TH-DEC-02 · lru_cache on an instance method keeps every instance alive

Easy & Tricky · Theory · Senior (5–8 years) · decorators, functools.lru_cache, memory leak, methods, weakref · 5 min

Question: A pricing service creates a RateCard object per request and memoises an expensive method with @functools.lru_cache(maxsize=128) directly on the method. Memory on the pods climbs slowly over the day. Can the cache be responsible, and what would you change?

Answer:

Trap: "The cache is per object, so it disappears when the request's RateCard is garbage-collected."

The decorator runs once, at class definition, so there is one cache shared by all instances, stored on the function object in the class. self is part of every cache key, so the cache holds a strong reference to each instance it has seen. Consequences:

  • Instances outlive their request. Up to maxsize RateCard objects (and everything they reference: DB rows, a config snapshot, an HTTP client) stay alive until evicted. With maxsize=None (or @functools.cache) that is every instance ever created: a true leak.
  • Instances evict each other. 128 slots are shared by all objects, so hit rates depend on traffic mix, not on one object's usage.
  • Keys need hashable self. With __eq__ defined and __hash__ left at None it fails outright; with a value-based __hash__, two "equal" objects share cached results, which may or may not be what you want.
  • Linters flag this: Ruff/flake8-bugbear rule B019.

Fixes, depending on intent:

  • Result depends only on the arguments (not on self): move it to a module-level cached function, or a staticmethod around one, and pass what it needs.
  • Per-instance memo of a computed attribute: functools.cached_property stores the value in the instance's __dict__, so it dies with the instance.
  • Per-instance cache with arguments: create it in __init__, e.g. self._rate = functools.lru_cache(maxsize=32)(self._rate_uncached). That creates a reference cycle (instance → cache → bound method → instance), which the cyclic GC collects, just not immediately.
  • Shared cache across requests (usually the real goal): an explicit cache keyed by the real inputs (state, hsn), with a size bound and a TTL, owned by something with an application lifetime.

import functools
import gc
import weakref

class RateCard:
    def __init__(self, state):
        self.state = state

    @functools.lru_cache(maxsize=128)
    def gst_rate(self, hsn):
        return 18

card = RateCard("KA")
card.gst_rate("8471")
ref = weakref.ref(card)
del card
gc.collect()
print("alive after del:", ref() is not None)
print(RateCard.gst_rate.cache_info())
RateCard("MH").gst_rate("8471")
print(RateCard.gst_rate.cache_info())

Output:

alive after del: True
CacheInfo(hits=0, misses=1, maxsize=128, currsize=1)
CacheInfo(hits=0, misses=2, maxsize=128, currsize=2)

Follow-ups:

  1. "How would you confirm in production that this is the leak, rather than guessing?" Compare heap snapshots over time: tracemalloc snapshots diffed by traceback, or count live objects by type (gc.get_objects() / objgraph-style counts) on a debug endpoint or a staging replay. A steadily growing count of RateCard instances whose referrer is the lru_cache wrapper confirms it. cache_info().currsize growing to maxsize is a quick hint.
  2. "Is cached_property safe if several threads read the attribute at the same time?" Since 3.12 it has no lock, so two threads may both compute the value and the last write wins. That is fine for pure computations; if the computation has side effects or is expensive, guard it with your own lock or compute it eagerly.
  3. "Why not just set maxsize=None so nothing is evicted?" Then nothing is ever released: every instance and every distinct argument tuple lives for the process lifetime. Unbounded caches in long-running services need a natural bound on the key space, which per-request objects never have.

Scoring: Strong: Explains the class-level shared cache holding strong references to self, the shared eviction, and picks a fix by intent (module-level cache, cached_property, per-instance cache, explicit TTL cache); knows how to confirm with tracemalloc or object counts. Acceptable: Knows the cache holds self and suggests cached_property, but misses that the cache is shared across instances. Red flag: Believes each instance has its own cache, or proposes maxsize=None as the fix.

SR-TH-DEC-03 · Designing a production retry decorator

Medium · Theory · Senior (5–8 years) · decorators, retries, backoff, idempotency, resilience · 6 min

Question: Your team wants one @retry decorator for all outbound calls: the payment gateway, the SMS provider and internal HTTP services. What design decisions does it have to make, and what defaults would you set?

Answer:

The mechanics (factory → decorator → wrapper) are easy; the policy is where incidents come from. Decisions I'd make explicit:

  • What is retryable. An explicit allow-list: connection errors, timeouts, HTTP 429/502/503/504. Never retry on validation errors, 4xx (other than 429), or auth failures; retrying a bug just multiplies load. Let callers pass a predicate (retry_if=lambda exc: ...) because "retryable" is domain-specific.
  • Only idempotent operations. Retrying a non-idempotent call after a timeout can double-charge: the first attempt may have succeeded. Payment and SMS calls get retries only with an idempotency key that is generated once, outside the retry loop, and sent on every attempt.
  • Backoff with jitter. Exponential backoff (base * 2**attempt) capped at a maximum, with full jitter (random.uniform(0, backoff)), so a fleet of pods doesn't retry in lockstep after a provider blip. Honour Retry-After when the server sends it.
  • A total deadline, not just an attempt count. "3 attempts" with a 30 s timeout each is 90 s plus sleeps; the caller's own request timeout is often 10 s. The decorator should stop when the remaining time budget can't fit another attempt.
  • Retry amplification. Retries at every layer multiply: 3 attempts at the API, 3 in the service, 3 in the client = 27 calls to a struggling dependency. Retry at one layer (usually the one closest to the failing call) and use a circuit breaker or retry budget (e.g. retries ≤ 10% of requests) to stop retry storms.
  • Observability. Log or count each retry with the attempt number, exception type and target; emit a metric for "gave up". On final failure re-raise the last original exception (or a wrapper from it), never a generic one.
  • Sync and async. Separate wrappers chosen at decoration time; the async version must use await asyncio.sleep, and must not catch CancelledError.
  • Testability. Sleep and clock injectable or patchable so tests run instantly; settings read at call time so they can be tuned without redeploying the decorated code.

Defaults I'd pick: 3 attempts, 0.2 s base, 5 s cap, full jitter, an overall deadline argument, retry only on an explicit exception tuple. In practice I'd wrap a maintained library (tenacity, or the HTTP client's transport retries) with team defaults rather than hand-roll the loop.

Follow-ups:

  1. "The gateway call timed out on attempt 1 and succeeded on attempt 2. How do you know the customer wasn't charged twice?" You don't, unless the gateway deduplicates by an idempotency key sent on both attempts, or you reconcile by querying the payment status before retrying. Timeouts mean "unknown outcome", not "failed".
  2. "Why is jitter needed if every pod already backs off exponentially?" Without jitter, pods that failed at the same moment retry at the same moments (0.2 s, 0.4 s, 0.8 s...), producing synchronised spikes that keep the dependency overloaded. Randomising spreads the load.
  3. "Where does the retry state live if the decorator is applied once at import time?" Per-call state (attempt counter, deadline) must be local variables inside the wrapper, never closure variables shared across calls; shared state like a circuit breaker must be thread-safe and is per process, so it behaves differently with 8 gunicorn workers than in a unit test.

Scoring: Strong: Covers retryable classification, idempotency keys generated outside the loop, backoff with jitter, a total deadline, retry amplification across layers, and observability; re-raises the original exception. Acceptable: Covers backoff and exception filtering but misses idempotency or retry amplification. Red flag: Retries every exception, retries non-idempotent payment calls without a key, or uses a fixed sleep with no cap or deadline.

SR-TH-DEC-04 · A function decorator stacked above @classmethod

Medium & Tricky · Theory · Senior (5–8 years) · decorators, classmethod, descriptors, decorator order · 5 min

Question: In a code review you see @audit placed above @classmethod on Invoice.from_row, while a sibling method has @classmethod above @audit. Tests only cover the sibling. Does the order matter?

Answer:

Trap: "Decorator order only changes which wrapper runs first; both versions work."

Decorators apply bottom-up, so the order decides what object each decorator receives:

  • @classmethod above @audit: audit gets a plain function and returns a plain wrapper; classmethod wraps that. On attribute access the classmethod descriptor binds cls and calls the wrapper with (cls, row). Works.
  • @audit above @classmethod: audit receives a classmethod object, not a function. functools.wraps copies its attributes happily, and the class body stores a plain function (the wrapper). Accessing Invoice.from_row returns that plain function, unbound, so no cls is passed; the wrapper then calls the classmethod object, which is not callable: TypeError: 'classmethod' object is not callable. (staticmethod objects became callable in 3.10, so the same mistake with @staticmethod may appear to work.)

The general rule: classmethod, staticmethod, property and functools.cached_property are descriptors that change how the attribute is looked up, so they belong outermost (on top). Function decorators that expect a callable go underneath. Since this fails only when the method is called, not at import, it slips past any test that doesn't call that path. Python 3.13 also removed classmethod's ability to wrap other descriptors (e.g. classmethod(property(...))), so stacking descriptors on descriptors is no longer an option either.

import functools

def audit(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("audit", func.__name__)
        return func(*args, **kwargs)
    return wrapper

class Invoice:
    @classmethod
    @audit
    def from_dict(cls, d):
        return cls()

    @audit
    @classmethod
    def from_row(cls, row):
        return cls()

print(type(Invoice.from_dict({})).__name__)
try:
    Invoice.from_row({})
except TypeError as exc:
    print("TypeError:", exc)

Output:

audit from_dict
Invoice
audit from_row
TypeError: 'classmethod' object is not callable

Follow-ups:

  1. "How would you make the team's decorators robust to being placed in the wrong order?" Check at decoration time: if isinstance(func, (classmethod, staticmethod)), either raise a clear TypeError("put @audit below @classmethod") or unwrap func.__func__, wrap it, and re-wrap in the same descriptor type. Failing loudly at import is better than failing on the first call in production.
  2. "What's the analogous mistake with @property?" Putting a function decorator above @property hands it a property object; the class ends up with a plain function instead of a property, so obj.total returns a bound method instead of a value, which is a silent bug rather than an error.
  3. "How would you catch this class of bug automatically?" A test that calls every public classmethod at least once, and a type checker: mypy/pyright flag a decorator typed Callable[P, R] -> Callable[P, R] being applied to a classmethod object, because it isn't a Callable.

Scoring: Strong: Explains bottom-up application, that the decorator receives a classmethod descriptor, the resulting unbound call and TypeError, and states the "descriptors outermost" rule with a decoration-time guard. Acceptable: Knows the order should be @classmethod on top but can't explain the descriptor mechanism. Red flag: Says order never matters, or that it only changes logging order.

SR-TH-DEC-05 · Typing a decorator that injects an argument

Hard · Theory · Senior (5–8 years) · decorators, typing, ParamSpec, Concatenate, inspect.signature, dependency injection · 7 min

Question: You're writing @with_session, which opens a DB session and passes it as the first argument, so callers write credit("ACC-42", 50_000) while the function is defined as credit(session, account, amount_paise). How do you type it so mypy checks the call sites, and what else must you fix so tools see the right signature?

Answer:

Typing. Use ParamSpec for "the rest of the parameters" and Concatenate to say the wrapped function takes a Session first, which the decorator removes:

def with_session[**P, R](func: Callable[Concatenate[Session, P], R]) -> Callable[P, R]: ...
# 3.11 and earlier: P = ParamSpec("P"); R = TypeVar("R")

Inside, the wrapper is def wrapper(*args: P.args, **kwargs: P.kwargs) -> R. Now credit("ACC-42", "50000") is a type error at the call site, and the decorated function's type is (account: str, amount_paise: int) -> str. Typing it as Callable[..., Any] would compile but erase all checking for every decorated function, which defeats the point of a shared decorator.

Runtime signature. functools.wraps sets __wrapped__, and inspect.signature follows it, so tools still see the original signature including session. For a plain helper that's harmless, but FastAPI, Typer, pytest fixtures and documentation generators read the runtime signature: FastAPI would try to treat session as a request parameter. Fix by setting wrapper.__signature__ to the original signature minus the first parameter; inspect.signature checks __signature__ before following __wrapped__.

Other design points.

  • Async functions need an async def wrapper (typed with Callable[Concatenate[Session, P], Awaitable[R]]), chosen at decoration time.
  • The session's lifetime must be tied to the call: open, commit/rollback and close inside the wrapper with a context manager, so an exception in the function rolls back.
  • Make injection overridable for tests (accept an explicit session, or inject a factory) rather than patching module globals.
  • For web handlers, prefer the framework's own dependency injection (Depends(get_session) in FastAPI) over a custom decorator: it already handles signatures, async, and teardown.

import functools
import inspect
from collections.abc import Callable
from typing import Concatenate

class Session:
    def __init__(self, name):
        self.name = name

def with_session[**P, R](func: Callable[Concatenate[Session, P], R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(Session("ledger-db"), *args, **kwargs)
    return wrapper

@with_session
def credit(session: Session, account: str, amount_paise: int) -> str:
    return f"{session.name}: credit {account} {amount_paise}"

print(credit("ACC-42", 50_000))
print(inspect.signature(credit))          # follows __wrapped__: still shows `session`

sig = inspect.signature(credit.__wrapped__)
credit.__signature__ = sig.replace(parameters=list(sig.parameters.values())[1:])
print(inspect.signature(credit))

Output:

ledger-db: credit ACC-42 50000
(session: __main__.Session, account: str, amount_paise: int) -> str
(account: str, amount_paise: int) -> str

Follow-ups:

  1. "Why can't Concatenate describe "remove a keyword-only parameter named session"?" Concatenate only prepends positional parameters to a ParamSpec; there is no way to express adding or removing a named keyword argument. Decorators that inject keyword arguments end up typed loosely or need a Protocol with __call__ per signature, which is one reason to inject positionally first.
  2. "How does a decorator that adds a parameter (e.g. timeout: float) get typed?" Return Callable[Concatenate[float, P], R] for a positional addition. For a keyword addition you need a callback Protocol, because Callable can't express named parameters.
  3. "If the original function already had __signature__ set before decorating, does the wrapper get it?" Yes. Function attributes live in the function's __dict__, and wraps merges the original's __dict__ into the wrapper's, so __signature__ is copied along with any other custom attributes. That's why the reliable place to set the reduced signature is on the wrapper, after wraps has run.

Scoring: Strong: Types it with ParamSpec and Concatenate (either syntax), explains that inspect.signature follows __wrapped__ and still shows the injected parameter, fixes __signature__, and mentions async and framework DI alternatives. Acceptable: Gets the ParamSpec/Concatenate typing right but doesn't consider the runtime signature that frameworks read. Red flag: Types the decorator as Callable[..., Any] or doesn't know how to preserve call-site checking.

SR-TH-DEC-06 · A timing and error-handling decorator on a generator function

Hard & Tricky · Theory · Senior (5–8 years) · decorators, generators, streaming, observability, exceptions · 7 min

Question: The team's @timed decorator logs start/end, measures duration and handles ValueError by logging it. Someone applies it to export_rows(), a generator that streams rows for a large CSV download. Dashboards show the export takes 0 ms and never errors, yet users report broken downloads. What's going on?

Answer:

Trap: "The decorator wraps the function, so it times and guards whatever the function does."

Calling a generator function doesn't run its body; it returns a generator object immediately. So the plain wrapper:

  • measures only the creation of the generator (≈0 ms), then logs "end" before a single row is produced;
  • has its try/except around that creation only. When the body later raises ValueError during iteration, the wrapper has long since returned, so the exception goes straight to the consumer. The "handled" metric stays at zero.
  • The same problem hits transaction or lock decorators: @transactional on a generator commits (or releases the lock) before any row is read, and the reads happen outside the transaction.

The same applies to async def (wrapper returns a coroutine) and async generators. In a streaming web response (FastAPI StreamingResponse), iteration happens later still, after the endpoint has returned.

Fixes:

  • Detect the kind at decoration time (inspect.isgeneratorfunction, inspect.iscoroutinefunction, inspect.isasyncgenfunction) and return a matching wrapper: a generator wrapper that does return (yield from func(*args, **kwargs)) inside try/finally, so timing covers full iteration and close()/throw() are forwarded. Or refuse unsupported kinds with a clear error.
  • Decide what "duration" means for a stream (time to first row vs time to exhaustion) and record both if they matter; also record early termination (GeneratorExit when the client disconnects).
  • For transactions and locks around generators, prefer an explicit with inside the generator so the resource lives exactly as long as the iteration.

import functools
import inspect

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"start {func.__name__}")
        try:
            return func(*args, **kwargs)
        except ValueError as exc:
            print(f"wrapper handled {exc}")
        finally:
            print(f"end {func.__name__}")
    return wrapper

def timed_gen_aware(func):
    if inspect.isgeneratorfunction(func):
        @functools.wraps(func)
        def gen_wrapper(*args, **kwargs):
            print(f"start {func.__name__}")
            try:
                return (yield from func(*args, **kwargs))
            finally:
                print(f"end {func.__name__}")
        return gen_wrapper
    return timed(func)

def export_rows(n):
    for i in range(n):
        if i == 2:
            raise ValueError("bad row 2")
        yield i

for deco in (timed, timed_gen_aware):
    rows = deco(export_rows)(5)
    print("-- got generator")
    try:
        for row in rows:
            print("row", row)
    except ValueError as exc:
        print("caller saw", exc)

Output:

start export_rows
end export_rows
-- got generator
row 0
row 1
caller saw bad row 2
-- got generator
start export_rows
row 0
row 1
end export_rows
caller saw bad row 2

Follow-ups:

  1. "Why yield from rather than for item in func(...): yield item?" yield from forwards send(), throw() and close() to the inner generator and returns its return value. A manual loop only forwards values: a sent value is lost, an exception thrown in surfaces in the wrapper instead of inside the inner generator, and on close() the inner generator is only closed when it's garbage-collected (immediately in CPython, later on other runtimes).
  2. "Why does "start" appear only after "got generator" in the fixed version?" The wrapper is now itself a generator function, so its body (including the "start" log) runs on the first next(). If you need to log at call time, split the wrapper into a normal function that logs and then returns an inner generator.
  3. "In a streaming HTTP response the client disconnects halfway. What does the fixed wrapper see?" The server stops iterating and closes the generator, which raises GeneratorExit at the paused yield; the finally still runs, so "end" is logged. Record it as "aborted" rather than success, by catching GeneratorExit, noting it, and re-raising.

Scoring: Strong: Explains that calling a generator function returns immediately so timing and try/except cover only creation; mentions the same issue for transactions/locks and async; fixes with a kind-aware wrapper using yield from and try/finally. Acceptable: Spots that the generator runs lazily and the timing is wrong, but doesn't address error handling or the fix's forwarding semantics. Red flag: Blames the logging backend or clock resolution, or suggests list(func()) inside the wrapper for a streaming export.

SR-TH-DM-01 · Which protocols a custom collection should implement, and what the fallbacks cost

Easy · Theory · Senior (5–8 years) · data model, protocols, __len__, __getitem__, __contains__, collections.abc, API design · 5 min

Question: Your team is writing an OrderBatch class that wraps a list of orders and is used across several services. Which special methods should it implement so callers can use len(), bool(), for, in and reversed() on it, and what does Python fall back to if you implement only some of them?

Answer:

Built-ins look for a specific special method and, if it's missing, fall back to an older or more general protocol:

Operation Tries first Falls back to
len(b) __len__ TypeError
bool(b) / if b: __bool__ __len__() != 0, else always True
iter(b) / for __iter__ __getitem__(0), __getitem__(1), … until IndexError
x in b __contains__ a linear scan via iteration
reversed(b) __reversed__ __len__ + __getitem__

So __len__ + __getitem__ alone already make the example work, but you get the fallbacks, not designed behaviour:

  • in becomes an O(n) scan. If the batch keeps an index (say, a dict by order ID), implement __contains__ to use it.
  • The __getitem__ iteration fallback assumes integer indexes from 0. On a mapping-like class it calls obj[0] and fails with a confusing KeyError.
  • bool() calls __len__. If __len__ is expensive (a lazy query object that runs COUNT(*)), every if results: hits the database. Django's QuerySet is a well-known example.

For a shared class, the senior answer is to inherit from collections.abc.Sequence (or Mapping). You implement the two abstract methods, the ABC supplies correct __iter__, __contains__, __reversed__, index and count, and isinstance(b, Sequence) is true, so libraries and type checkers treat it properly. Then override the mixins whose default is too slow for your data.

class OrderBatch:
    def __init__(self, orders):
        self._orders = list(orders)

    def __len__(self):
        return len(self._orders)

    def __getitem__(self, i):
        return self._orders[i]


batch = OrderBatch(["A-101", "A-102"])
print(len(batch), bool(batch), bool(OrderBatch([])))
print(list(batch))
print("A-102" in batch, "Z-999" in batch)
print(list(reversed(batch)))

Output:

2 True False
['A-101', 'A-102']
True False
['A-102', 'A-101']

Follow-ups:

  1. "Why prefer subclassing collections.abc.Sequence over list?" Subclassing list inherits dozens of methods (append, sort, __iadd__, slicing that returns a plain list) that bypass your invariants, and several built-in methods don't call your overrides (e.g. extend doesn't go through your append). The ABC gives you only what you implement plus consistent mixins, so the class's contract stays under your control.
  2. "OrderBatch is a lazy wrapper over a paginated API. Should it define __len__?" Probably not. len() would need to fetch everything or make a count call, and bool() would silently trigger it. Expose an explicit count() method, and implement __iter__ only, so callers can see the cost.

Scoring: Strong: Knows the fallback chain (including bool → __len__ and the legacy __getitem__ iteration protocol), names the performance and correctness costs, and recommends collections.abc mixins for a shared class. Acceptable: Lists the right dunders for each operation but doesn't know the fallbacks or why they matter. Red flag: Suggests subclassing list without seeing the downside, or thinks in needs __contains__ to work at all.

SR-TH-DM-02 · `x = x or default` turns valid falsy values into defaults

Easy & Tricky · Theory · Senior (5–8 years) · truthiness, __bool__, __len__, defaults, API design, idempotency · 4 min

Question: A shared job-submission helper starts with batch = batch or Batch(["default-job"]), timeout = timeout or 30 and retries = retries or 3. A caller passes an empty batch, timeout=0 and retries=0. What does the helper actually use, and what's the production risk?

Answer:

Trap: "or only falls back when the argument is None."

or falls back on any falsy value. The caller's three explicit choices are all overridden: the empty batch is falsy because its __len__ returns 0, and 0 is falsy, so the helper submits the default job with a 30 s timeout and 3 retries.

The dangerous one is retries=0. Callers pass it on purpose for non-idempotent operations such as a payment capture, where a retry after a timeout can charge the customer twice. The helper retries anyway. Other falsy values that get eaten the same way: 0.0, Decimal("0"), "", [], {}, and any object whose __bool__ or __len__ says false. NumPy arrays and pandas DataFrames go further: df or default raises ValueError: The truth value of a DataFrame is ambiguous.

The rule for API code: use None as the "not given" marker and test it with is None:

if retries is None:
    retries = 3

If None is itself a meaningful value, use a private sentinel (see SR-TH-DM-06). Keep x or default for cases where every falsy value really should mean "use the default", such as display strings.

class Batch:
    def __init__(self, items):
        self.items = list(items)

    def __len__(self):
        return len(self.items)


def submit(batch=None, timeout=None, retries=None):
    batch = batch or Batch(["default-job"])
    timeout = timeout or 30
    retries = retries or 3
    return len(batch), timeout, retries


print(submit(Batch([]), timeout=0, retries=0))
print(submit(None, timeout=None, retries=None))

Output:

(1, 30, 3)
(1, 30, 3)

Follow-ups:

  1. "How would you catch this class of bug in review or CI rather than in production?" A test per parameter that passes the falsy edge value (0, empty collection) and asserts it's honoured. Linters help only a little (some flag or with numeric defaults), so it's mostly a review checklist item for any function with optional numeric or collection parameters.
  2. "Is if not batch: ever the right check inside the helper?" Yes, when the intent is "nothing to do" (return early for an empty batch). The bug is conflating "not provided" with "empty". Those are different states and deserve different checks: batch is None for the first, not batch or len(batch) == 0 for the second.

Scoring: Strong: Spots that all three explicit values are overridden, explains truthiness via __bool__/__len__, raises the non-idempotent retry risk, and states the is None/sentinel rule. Acceptable: Spots the timeout=0 problem and fixes with is None, but misses the empty-batch case or the retry risk. Red flag: Thinks or only replaces None, or sees no problem because "0 timeout makes no sense anyway".

SR-TH-DM-03 · Why a __getattr__ proxy doesn't proxy len(), with or operators

Medium · Theory · Senior (5–8 years) · data model, special method lookup, proxies, __getattr__, mocking · 5 min

Question: You write a tracing proxy that forwards everything to a wrapped object via __getattr__. proxy.count(x) and even proxy.__len__() work, but len(proxy) raises TypeError. Why, and how do you build a proxy that works?

Answer:

Implicit special-method lookup goes to the type, not the instance. When Python evaluates len(x), x + y, with x:, for … in x or hash(x), it looks up __len__, __add__, __enter__ and so on directly on type(x). It skips the instance __dict__, and it skips __getattr__ and __getattribute__. An explicit proxy.__len__() is an ordinary attribute access, so it goes through __getattr__ and works. That's why the two calls behave differently.

The reasons are speed (a slot lookup on the type, no dictionary search) and correctness for classes as objects: hash(int) must use type(int).__hash__, not the unbound int.__hash__.

Consequences you meet in real code:

  • Proxies and wrappers must define every dunder they want to forward on the class. That's what wrapt.ObjectProxy and similar libraries do, explicitly.
  • Mocks: unittest.mock.Mock doesn't support len() or with. MagicMock does, because it preconfigures those methods on the mock's class.
  • Monkeypatching an instance (obj.__len__ = lambda: 0) has no effect on len(obj). You'd have to patch the class, which affects every instance.

A sound design is to forward the dunders you need explicitly, or, if you control the wrapped type, prefer composition with a narrow, explicit interface over a "transparent" proxy.

class Traced:
    def __init__(self, target):
        self._target = target

    def __getattr__(self, name):
        print(f"proxy -> {name}")
        return getattr(self._target, name)


orders = Traced(["A-101", "A-102"])
print(orders.count("A-101"))
print(orders.__len__())
try:
    len(orders)
except TypeError as e:
    print("TypeError:", e)

Output:

proxy -> count
1
proxy -> __len__
2
TypeError: object of type 'Traced' has no len()

Follow-ups:

  1. "Why does MagicMock support with mock: but Mock doesn't?" Because with looks up __enter__/__exit__ on the type. MagicMock creates a per-instance subclass and sets those methods on it, so the type-level lookup finds them. Mock only answers attribute access through __getattr__, which implicit lookup never consults.
  2. "Does isinstance(proxy, list) return True for this proxy?" No: isinstance checks type(proxy) (and proxy.__class__, which here goes through normal attribute lookup and finds Traced). Libraries like wrapt make __class__ a property that returns the wrapped object's class, so isinstance passes. It's a deliberate trade-off that can surprise debugging.

Scoring: Strong: States that implicit dunder lookup uses the type and bypasses __getattr__/instance dict, explains why, and connects it to proxies, MagicMock and instance monkeypatching. Acceptable: Knows the proxy needs to define __len__ explicitly but can't explain the lookup rule. Red flag: Suggests the fix is __getattribute__ instead of __getattr__ (it doesn't help), or thinks len() calls obj.__len__() the same way an explicit call does.

SR-TH-DM-04 · A property's AttributeError gets swallowed by __getattr__

Medium & Tricky · Theory · Senior (5–8 years) · data model, __getattr__, properties, attribute lookup, debugging · 5 min

Question: A Settings class serves config keys through __getattr__ and also has a computed db_url property. After a refactor, settings.db_url fails with AttributeError: 'Settings' has no setting 'db_url', and the traceback only shows __getattr__. The property is clearly defined. What's going on?

Answer:

Trap: "The property must have been deleted or shadowed. The error message names the attribute that's missing."

__getattr__ is called whenever normal attribute lookup raises AttributeError, and an AttributeError raised inside a property getter counts. Here the refactor introduced a typo, .removeprefx(...) on a str. That raises AttributeError inside db_url. Python treats this as "db_url not found", discards that exception, and calls __getattr__("db_url"). The fallback then reports db_url as the missing name. The real error, its line and its message never appear in the traceback.

The same shape appears with any descriptor plus a catch-all __getattr__: ORMs, config objects, API-response wrappers. hasattr() has a similar blind spot, because it returns False for any AttributeError, including one from a bug inside a property.

Fixes, from most to least preferred:

  • Avoid a catch-all __getattr__ on classes that also have properties. Use explicit fields (dataclass, pydantic model) or settings["key"].
  • In __getattr__, refuse names that exist on the class: if name in dir(type(self)): raise RuntimeError(...). That surfaces the bug instead of masking it.
  • Inside properties that call code you don't control, catch AttributeError and re-raise a different type (raise RuntimeError("db_url failed") from e), so it can't be mistaken for a missing attribute.

class Settings:
    def __init__(self, raw):
        self._raw = raw

    def __getattr__(self, name):
        try:
            return self._raw[name]
        except KeyError:
            raise AttributeError(f"{type(self).__name__!r} has no setting {name!r}") from None

    @property
    def db_url(self):
        host = self._raw["db_host"].removeprefx("tcp://")   # typo: removeprefix
        return f"postgres://{host}:{self._raw['db_port']}"


s = Settings({"db_host": "tcp://10.0.0.5", "db_port": 5432})
print(s.db_port)
print(s.db_url)

Output:

5432
AttributeError: 'Settings' has no setting 'db_url'

Follow-ups:

  1. "Would switching to __getattribute__ avoid the problem?" No, it makes things worse. __getattribute__ runs for every attribute access, including self._raw, so it's easy to recurse infinitely, and it's slower. The underlying issue is that AttributeError means "missing" to the lookup machinery, so you have to stop bugs from raising that type.
  2. "Where else does this bite in production code?" In hasattr()-based feature detection (if hasattr(client, "stream")), which hides bugs inside properties. And in templating or serialisation layers that call getattr(obj, name, default): a broken property silently renders the default instead of failing.

Scoring: Strong: Explains that an AttributeError raised inside the property triggers __getattr__, so the original error is lost; mentions hasattr and gives a structural fix rather than only "fix the typo". Acceptable: Works out the mechanism with a hint, and fixes the typo plus adds a narrower __getattr__. Red flag: Believes the property is missing or overridden, or debugs by adding more try/except AttributeError.

SR-TH-DM-05 · What obj.attr actually does, and why cached_property can cache

Hard · Theory · Senior (5–8 years) · data model, attribute lookup, descriptors, property, cached_property, __getattr__ · 7 min

Question: Walk through what obj.attr does for an ordinary class instance, in order. Use it to explain why you can't override a property by writing to the instance __dict__, but functools.cached_property can serve its cached value straight from the instance __dict__.

Answer:

object.__getattribute__(obj, "attr") does this:

  1. Look up "attr" on type(obj) and its MRO. If it's found and is a data descriptor (defines __set__ or __delete__, like property, __slots__ members, or pydantic and ORM fields), call its __get__ and return. Data descriptors win over the instance.
  2. Otherwise, if "attr" is in obj.__dict__, return that.
  3. Otherwise, if step 1 found a non-data descriptor (only __get__: plain functions, which is how methods bind, classmethod, staticmethod, cached_property), call its __get__. If it found a plain class attribute, return it.
  4. Otherwise raise AttributeError, and if the class defines __getattr__, call that instead.

So property is a data descriptor: writing to obj.__dict__["balance"] doesn't change what obj.balance returns. cached_property is deliberately non-data. On first access, step 3 runs the function and stores the result in obj.__dict__. Later reads stop at step 2, a plain dict hit with no function call. del obj.attr removes the dict entry, so the next access recomputes, which gives you invalidation.

Design consequences a senior engineer should know:

  • cached_property needs an instance __dict__, so it doesn't work with __slots__ unless you add "__dict__".
  • Since 3.12 cached_property has no lock. Two threads can compute the value twice, so guard expensive or side-effecting computations yourself.
  • Because methods are non-data descriptors, self.process = other_fn in an instance shadows the method. Sometimes that's useful in tests, but it's a source of confusing bugs.

from functools import cached_property


class Account:
    def __init__(self, balance_paise):
        self._balance = balance_paise

    @property
    def balance(self):
        return self._balance

    @cached_property
    def risk_score(self):
        print("computing risk")
        return 42


a = Account(150_000)
a.__dict__["balance"] = 0          # cannot shadow a data descriptor
print(a.balance)
print(a.risk_score, a.risk_score)  # computed once, then read from a.__dict__
a.__dict__["risk_score"] = 7       # instance dict shadows a non-data descriptor
print(a.risk_score)
del a.risk_score                   # invalidate
print(a.risk_score)

Output:

150000
computing risk
42 42
7
computing risk
42

Follow-ups:

  1. "How does __slots__ fit into this order?" Each slot is a member descriptor on the class, and it's a data descriptor. Lookup finds it at step 1 and reads a fixed offset in the instance, with no __dict__ involved. That's where the memory saving comes from, and also why you can't add new attributes or use cached_property without a __dict__.
  2. "Where in this order would you hook in to log every attribute access for debugging, and what does it cost?" Override __getattribute__, which runs before all four steps on every access. It's heavy: it slows every attribute read, including internal ones, and must call super().__getattribute__ to avoid infinite recursion. For a targeted need, wrap specific attributes in a descriptor instead.

Scoring: Strong: Gives the four-step order with data vs non-data descriptors, explains cached_property via step 2, and mentions __slots__ and the lack of a lock since 3.12. Acceptable: Knows instance dict, class, then __getattr__, and that properties are special, but is vague on the data/non-data distinction. Red flag: Thinks the instance __dict__ is always checked first, or that __getattr__ runs on every access.

SR-TH-DM-06 · Sentinel identity doesn't survive pickling, copying or a double import

Hard & Tricky · Theory · Senior (5–8 years) · object identity, sentinels, pickle, copy, enum, imports, multiprocessing · 6 min

Question: Your library uses MISSING = object() as a "no value supplied" sentinel and checks if value is MISSING:. It works in unit tests, but after config objects pass through a ProcessPoolExecutor or copy.deepcopy, the check stops matching and defaults are never applied. Why? How else can object identity break across a codebase, and how do you design a sentinel that survives?

Answer:

Trap: "is against a module-level constant is always safe, because there's only one MISSING."

There's only one per process, and only until something reconstructs the object. pickle (used by every multiprocessing executor, Celery, caches) and copy.deepcopy (and even copy.copy) build a brand-new object() on the other side. value is MISSING then compares against a different object and is False. The original object survives only when it's serialised by reference, which is how pickle handles classes, functions and Enum members: it stores the qualified name and looks it up again.

Other ways identity silently breaks:

  • The same module imported under two names, e.g. both app.models and src.app.models because of a messy sys.path. You get two module objects, two Order classes, two registries and two caches, and isinstance(order, Order) fails across the boundary.
  • importlib.reload or a hot-reloading dev server creates new class objects, so instances created before the reload fail isinstance checks and identity checks against reloaded constants.

A sentinel that survives:

class _Missing(enum.Enum):
    MISSING = "MISSING"
MISSING = _Missing.MISSING

An Enum member pickles by name, deepcopy returns the same member, it has a readable repr, and type checkers understand Literal[_Missing.MISSING] for narrowing. Alternatively, give a small class __reduce__ returning its global name, plus __copy__/__deepcopy__ returning self. For duplicate imports, the fix is packaging hygiene: one installable package, src/ layout, no path hacks.

import copy
import enum
import pickle

MISSING = object()


class _Missing(enum.Enum):
    MISSING = "MISSING"


def roundtrip(x):
    return pickle.loads(pickle.dumps(x))


print(roundtrip(MISSING) is MISSING, copy.deepcopy(MISSING) is MISSING)
print(roundtrip(_Missing.MISSING) is _Missing.MISSING, copy.deepcopy(_Missing.MISSING) is _Missing.MISSING)

Output:

False False
True True

Follow-ups:

  1. "Why not just use None as the sentinel?" Because None is often a legitimate value ("clear this field", JSON null). A distinct sentinel separates "not supplied" from "explicitly set to None", which matters for PATCH-style updates. That's also why pydantic and dataclasses have their own internal MISSING-style markers.
  2. "How would you detect the double-import problem in a running service?" Look in sys.modules for two keys that point to the same file (m.__file__). A cheap startup check that fails loudly on that is worth having. Symptoms are a cache or registry that "forgets" entries, or isinstance failing for objects that print the same class name.

Scoring: Strong: Explains that pickle/deepcopy reconstruct the sentinel, contrasts by-reference pickling (Enum, classes), mentions double imports or reload, and proposes an Enum or __reduce__ sentinel. Acceptable: Identifies that multiprocessing copies the object and suggests comparing by value or using an Enum, without the broader identity picture. Red flag: Suggests switching to == on a bare object() (still False), or blames the executor rather than object reconstruction.

SR-TH-DS-01 · Setting up an A/B test before any data is collected

Easy · Theory · Senior (5–8 years) · experimentation, A/B testing, power analysis, randomisation unit, guardrail metrics · 6 min

Question: Product wants to test a new checkout page for a food-delivery app and "see if it does better". You own the analysis. What do you pin down before the experiment starts?

Answer:

Most bad experiment readouts are decided before launch, so I'd write a short pre-registration covering:

  1. Hypothesis and primary metric, chosen up front: e.g. order conversion per user who reaches checkout. One primary metric; the rest are secondary or guardrails. Choosing the metric after seeing results is p-hacking.
  2. Guardrail metrics that must not get worse: payment failures, latency, refunds, cancellations, average order value.
  3. Randomisation unit and its consistency with the metric: randomise by user (not session or request), so a person sees one variant across visits; analyse at the same unit, or use the delta method for ratio metrics like "orders per session". If there are network effects (shared carts, restaurant capacity), consider cluster or switchback designs.
  4. Assignment and exposure: deterministic hashing of user ID with an experiment-specific salt; log exposure when the user actually sees checkout, and analyse only exposed users in both arms.
  5. Sample size from a power analysis: baseline rate, the minimum detectable effect worth acting on (say +0.5 percentage points), alpha 0.05, power 0.8. That gives users per arm and therefore duration, rounded up to whole weeks to cover weekday and weekend behaviour (and salary-day effects at month start).
  6. Decision rule: what result leads to ship, iterate or abandon, including what happens if the primary metric wins but a guardrail loses.
  7. Health checks: a sample-ratio-mismatch test (chi-square on assignment counts) and an A/A period or pre-period comparison to validate the pipeline.

And one analysis rule: no stopping early because the dashboard looks good, unless the design is sequential from the start.

Follow-ups:

  1. "The power analysis says 6 weeks, but product wants an answer in one week. What are your options?" Raise the minimum detectable effect (accept that only a large effect can be detected), use a more sensitive metric closer to the change (checkout completion rather than weekly orders), reduce variance with pre-period covariates (CUPED), or increase the traffic share in the test. What I won't do is run one week and present an underpowered null as 'no effect'.
  2. "Why randomise by user rather than by request, if requests give far more samples?" Requests from the same user aren't independent, so treating them as such understates the variance and inflates false positives; and a user flipping between variants gets a confusing experience that contaminates both arms. Randomise at the level where the treatment is experienced and analyse at that level.

Scoring: Strong: Pre-registers a single primary metric plus guardrails; matches randomisation and analysis units; sizes the test from an MDE with power and whole-week duration; adds SRM checks and a decision rule. Acceptable: Mentions a metric, random assignment and statistical significance, but no power analysis or unit-of-analysis reasoning. Red flag: "Split traffic 50/50 and stop when it's significant", or picks the metric after looking at results.

SR-TH-DS-02 · Peeking at an A/B dashboard every day

Easy & Tricky · Theory · Senior (5–8 years) · experimentation, p-values, peeking, sequential testing, simulation · 6 min

Question: A product manager checks the experiment dashboard every morning and plans to stop the test the first day the p-value drops below 0.05. Each daily check uses a correct two-sample test. Is the false-positive rate still 5%?

Answer:

Trap: "Yes; each test is valid at alpha = 0.05, so the overall error rate is 5%."

Each look has a 5% false-positive chance if it's the only look. Stopping at the first of many looks gives the noise many chances to cross the line, and the cumulative z-statistics on consecutive days are highly correlated random walks that wander past ±1.96 far more often than a single fixed-horizon test. The snippet simulates 2,000 A/A tests (no real difference) over 14 days: looking once at the end gives about 5% false positives; stopping at the first daily p < 0.05 gives about 22%. Run long enough with continuous monitoring, and the false-positive rate approaches 100%.

What I'd put in place:

  • Fixed horizon: decide the sample size in advance, and the dashboard shows the p-value as "not decision-ready" until then. Daily monitoring is still fine for guardrails and bugs (crashes, a sample-ratio mismatch), just not for declaring a winner.
  • If early stopping is a real need, design for it: group-sequential tests with alpha spending (O'Brien–Fleming boundaries are very strict early and close to 1.96 at the end), or always-valid inference (mSPRT, confidence sequences), which many experimentation platforms implement.
  • Same family of problem: testing 20 metrics or 10 segments and reporting whichever is significant. Pre-register, and correct for multiple comparisons (Holm, Benjamini–Hochberg) for secondary analyses.

import numpy as np

rng = np.random.default_rng(2026)
n_experiments, days, users_per_day, p = 2_000, 14, 500, 0.10   # A/A: no real effect

# Daily conversions per arm, then running totals after each day.
a = rng.binomial(users_per_day, p, size=(n_experiments, days)).cumsum(axis=1)
b = rng.binomial(users_per_day, p, size=(n_experiments, days)).cumsum(axis=1)
n = users_per_day * np.arange(1, days + 1)

pooled = (a + b) / (2 * n)
z = (b / n - a / n) / np.sqrt(pooled * (1 - pooled) * 2 / n)
significant = np.abs(z) > 1.96                      # two-sided, alpha = 0.05

print(f"look once at day {days}: {significant[:, -1].mean():.1%} false positives")
print(f"peek daily, stop at first p<0.05: {significant.any(axis=1).mean():.1%} false positives")

Output:

look once at day 14: 4.5% false positives
peek daily, stop at first p<0.05: 21.9% false positives

Follow-ups:

  1. "Why does the simulation use an A/A setup rather than a real effect?" With no true difference, every 'significant' result is by definition a false positive, so the fraction of significant runs directly estimates the false-positive rate of the procedure. It's also how you validate an experimentation platform's statistics before trusting them.
  2. "The test ran its full planned duration and p = 0.06. Can you extend it a week to get significance?" Not under the original fixed-horizon analysis; that's peeking with an extra step and inflates the error rate. Report the estimate with its confidence interval (the effect may be real but small), and if the decision needs more precision, plan a new, properly powered test or use a sequential design from the start.

Scoring: Strong: Explains why repeated looks inflate the error (correlated cumulative statistics, many chances); quantifies it roughly; separates monitoring for harm from deciding a winner; names group-sequential or always-valid methods; links to multiple comparisons. Acceptable: Knows peeking inflates false positives and recommends a fixed sample size, but can't say what to do when early stopping is required. Red flag: Says each test is valid so peeking is fine, or recommends lowering alpha only on the last day.

SR-TH-DS-03 · Fitting a large feature table into memory

Medium · Theory · Senior (5–8 years) · pandas, memory, dtypes, categoricals, Parquet, Arrow, chunking · 6 min

Question: A feature table of 80 million order rows exports to a 9 GB CSV, and pd.read_csv kills the 32 GB notebook server. How do you get this analysis to run, and what do you standardise for the team?

Answer:

First understand why 9 GB on disk becomes more than 32 GB in RAM: default dtypes. Every integer becomes int64 and every float float64 (8 bytes each), and each text value becomes a separate Python str object in an object column (about 47 bytes for a short ASCII string like "Mumbai", plus an 8-byte pointer), even when there are only five distinct cities. Parsing also needs temporary memory on top. The snippet shows a million rows dropping from about 71 MB to 6 MB just by choosing dtypes.

Steps, roughly in order of payoff:

  1. Read only what you need: usecols=[...], and filter early (date range, one region).
  2. Choose dtypes: category for low-cardinality strings (city, payment method), the smallest safe integer (int8/int32, or nullable Int32 if missing values exist), float32 where its 7 significant digits are enough (features, not money), bool for flags. Pass dtype= to the reader so the large intermediate never exists.
  3. Use a columnar format: convert the CSV to Parquet once. Reads are much faster, dtypes are preserved, and columns=/filters= push pruning into the reader. With dtype_backend="pyarrow", string columns use Arrow memory rather than Python objects.
  4. Don't materialise everything: aggregate in chunks (chunksize=) when the result is small, or push the work into DuckDB or Polars, which run lazily, multi-threaded, and out of core over Parquet, returning only the aggregate to pandas.
  5. Sample when the analysis allows it (a stratified 5% sample is often enough to iterate on features), then run the final version on the full data.
  6. Watch for hidden copies: chained operations and apply create intermediates; measure with df.memory_usage(deep=True) (without deep=True, object columns only count pointers).

Team standard: datasets are shared as Parquet with a documented schema; ad-hoc CSV exports are for humans, not pipelines.

import numpy as np
import pandas as pd

n = 1_000_000
rng = np.random.default_rng(0)
df = pd.DataFrame({
    "city": rng.choice(["Mumbai", "Pune", "Delhi", "Chennai", "Kolkata"], size=n),
    "qty": rng.integers(1, 20, size=n),          # int64 by default
    "price": rng.uniform(10, 5000, size=n),      # float64
})

def mb(frame: pd.DataFrame) -> float:
    return frame.memory_usage(deep=True).sum() / 1e6

print(f"default dtypes: {mb(df):6.1f} MB")
slim = df.astype({"city": "category", "qty": "int8", "price": "float32"})
print(f"after astype:   {mb(slim):6.1f} MB")
print(slim.dtypes.astype(str).to_dict())

Output:

default dtypes:   70.8 MB
after astype:      6.0 MB
{'city': 'category', 'qty': 'int8', 'price': 'float32'}

Follow-ups:

  1. "When is category the wrong choice for a string column?" When cardinality is high (order IDs, free text): the categories array then holds nearly every value plus a codes array, so there's no saving, and operations that combine frames with different category sets fall back to object or raise. Also beware groupby on categoricals: in pandas 2.x, observed=False is still the default and produces rows for every category, including empty combinations.
  2. "Why not just get a 128 GB machine?" Sometimes that's the right call: a bigger VM for a day is cheaper than an engineer's week. But default dtypes waste 5–10×, so the same habits that save memory also make every operation faster, and the next dataset will be bigger again. Fix the representation first, then size the machine.

Scoring: Strong: Explains where the memory goes (int64/float64, Python string objects, parse overhead); applies usecols, dtypes at read time, Parquet with pruning, chunking or DuckDB/Polars; measures with deep memory usage; sets a team format standard. Acceptable: Knows to use smaller dtypes, categories and chunks, but doesn't explain object-column overhead or columnar formats. Red flag: Only suggests a bigger machine or Spark, or measures memory without deep=True and concludes strings are cheap.

SR-TH-DS-04 · Variant B wins on every device and loses overall

Medium & Tricky · Theory · Senior (5–8 years) · Simpson's paradox, aggregation, experimentation, sample ratio mismatch, pandas · 6 min

Question: In a payments-page experiment, variant B converts better than A on desktop and on mobile, but worse overall. A junior analyst assumes the overall number has an aggregation bug. What's going on, and what do you check next?

Answer:

Trap: "One of the numbers must be computed wrong; a variant can't win in every segment and lose in total."

It can. This is Simpson's paradox: the overall rate is a weighted average of the segment rates, and the two variants have very different weights. In the snippet, 80% of A's users are on desktop (which converts at about 15%) while 80% of B's users are on mobile (about 5%). B is 1 point better in each segment, but its traffic is concentrated in the low-converting segment, so its overall rate is 8% vs A's 13%. The arithmetic is right; the comparison is confounded by device mix.

In an observational analysis, the fix is to compare within segments (or reweight to a common mix) and to ask what the causal question is. In a randomised experiment, though, the paradox itself is a red flag: randomisation should give both arms the same device mix, up to noise. A 80/20 vs 20/80 split means assignment or exposure is broken. Next checks:

  • Sample-ratio mismatch overall and per segment (chi-square of observed vs expected counts).
  • How assignment and exposure are logged: e.g. B only rendered in the mobile app version that shipped the flag, desktop B users crashing before the exposure event fired, a bot filter that treats one variant's traffic differently, or a redirect that drops tracking.
  • Whether the experiment's triggering condition depends on the treatment (only users who reached a page that B itself changed).

Until that's explained, neither the overall nor the per-segment result is trustworthy, because the same bug that skews the mix may also select which users are counted.

import pandas as pd

# Checkout conversions per variant and device for a ₹ payments-page experiment.
df = pd.DataFrame({
    "variant": ["A", "A", "B", "B"],
    "device":  ["desktop", "mobile", "desktop", "mobile"],
    "users":   [8_000, 2_000, 2_000, 8_000],
    "orders":  [1_200, 100, 320, 480],
})

seg = df.assign(rate=df.orders / df.users).pivot(index="device", columns="variant", values="rate")
print(seg.round(3))
overall = df.groupby("variant")[["orders", "users"]].sum()
print((overall.orders / overall.users).round(3).to_dict())

Output:

variant     A     B
device
desktop  0.15  0.16
mobile   0.05  0.06
{'A': 0.13, 'B': 0.08}

Follow-ups:

  1. "Suppose the device mix had been balanced but you still found B wins on mobile and loses on desktop. Ship to mobile only?" Be careful: with many segments, some will show opposite effects by chance. Treat per-segment effects as hypotheses unless the segment was pre-registered, check whether the interaction is statistically meaningful and plausible (e.g. a layout that breaks on small screens), and confirm with a follow-up test targeted at that segment.
  2. "Why is the overall rate computed as total orders over total users rather than the mean of the two segment rates?" The mean of rates weights each segment equally regardless of size, which answers a different question. The overall conversion is the ratio of sums. Mixing up 'average of ratios' and 'ratio of averages' is a common source of conflicting dashboards.

Scoring: Strong: Names Simpson's paradox and explains it as differing weights; recognises that in a randomised test an unbalanced mix signals broken assignment or exposure; checks SRM and logging before interpreting any result. Acceptable: Explains the weighting and suggests comparing within segments, but treats the experiment result as valid. Red flag: Insists one figure is a bug, or picks whichever view supports shipping.

SR-TH-DS-05 · Variance reduction with pre-period data (CUPED)

Hard · Theory · Senior (5–8 years) · experimentation, variance reduction, CUPED, regression adjustment, numpy · 8 min

Question: Your experiments on average spend per user need six weeks to reach significance, because spend is extremely skewed. How would you use pre-experiment data to shorten them, how does it work, and what can go wrong?

Answer:

Use CUPED (controlled experiments using pre-experiment data), which is regression adjustment on a covariate that the treatment can't affect.

How it works: much of the variance in post-period spend Y is just "heavy spenders keep spending heavily". Take the same metric from before the experiment, X, and form

Y_adj = Y - θ (X - mean(X)), with θ = cov(X, Y) / var(X)

(the OLS slope of Y on X, estimated on pooled data from both arms). Because X was measured before assignment, it's independent of treatment, so E[Y_adj] differs between arms by exactly the same treatment effect, but Var(Y_adj) = Var(Y)(1 - ρ²), where ρ is the correlation of X and Y. With ρ ≈ 0.9 the variance drops by about 80%, which is like running the test with about 4–5 times as many users. In the snippet the true lift is ₹25; the raw estimate is ₹31 ± ₹18, the adjusted one ₹27 ± ₹8, with a 78% variance reduction.

Things that go wrong:

  • Covariate affected by treatment: using data from after assignment (even one day of overlap) biases the estimate. The covariate window must end before exposure.
  • New users have no pre-period: set X to a constant (e.g. 0) plus an indicator for "no history", or adjust only on the subset; don't drop them, or you change the population.
  • Weak correlation gives little gain; choose the covariate by measured ρ (the same metric's history is usually best; several covariates can be combined with regression).
  • Heavy tails still dominate: cap or winsorise spend at a pre-registered percentile (from pre-period data) as well, and report the capping.
  • The unit of analysis for θ must match randomisation; ratio metrics need the delta method alongside.
  • It must be pre-registered as the analysis method, not applied only when the raw result disappoints.

import numpy as np

rng = np.random.default_rng(11)
n = 20_000
pre = rng.gamma(shape=2.0, scale=500.0, size=n)            # ₹ spend in the 4 weeks before
treated = rng.integers(0, 2, size=n).astype(bool)          # random assignment
post = 0.8 * pre + rng.normal(0, 300, size=n) + 25 * treated   # true lift: ₹25

theta = np.cov(pre, post)[0, 1] / np.var(pre, ddof=1)
adjusted = post - theta * (pre - pre.mean())

def lift_and_se(y: np.ndarray) -> tuple[float, float]:
    t, c = y[treated], y[~treated]
    return t.mean() - c.mean(), np.sqrt(t.var(ddof=1) / len(t) + c.var(ddof=1) / len(c))

for name, y in [("raw", post), ("CUPED", adjusted)]:
    lift, se = lift_and_se(y)
    print(f"{name:5}: lift = ₹{lift:6.1f}, 95% CI ± ₹{1.96 * se:5.1f}")
print(f"variance reduction: {1 - adjusted.var() / post.var():.0%}")

Output:

raw  : lift = ₹  31.3, 95% CI ± ₹ 17.8
CUPED: lift = ₹  26.8, 95% CI ± ₹  8.3
variance reduction: 78%

Follow-ups:

  1. "Why do the raw and CUPED point estimates differ (₹31 vs ₹27) if both are unbiased?" Both are unbiased across repeated experiments, but in this one sample the treatment arm happened to have slightly higher pre-period spend by chance. CUPED corrects for that chance imbalance, which is exactly where its precision comes from; the raw estimate absorbs it as noise.
  2. "How is CUPED related to just running a regression post ~ treated + pre?" They're essentially the same estimator: OLS with the pre-period covariate gives the treatment coefficient with the same variance reduction. The regression form extends naturally to multiple covariates and gives standard errors directly (use heteroscedasticity-robust ones for skewed spend).

Scoring: Strong: Gives the adjustment and why it's unbiased (pre-assignment covariate); relates variance reduction to ρ² and to sample size; covers leakage from post-assignment data, new users, heavy tails and pre-registration. Acceptable: Knows CUPED uses pre-period data to reduce variance and can describe the formula, but misses the pitfalls. Red flag: Proposes controlling for a post-treatment variable, or dropping users without history, or applying the adjustment only when the raw result isn't significant.

SR-TH-DS-06 · High AUC, wrong probabilities after undersampling

Hard & Tricky · Theory · Senior (5–8 years) · calibration, AUC, class imbalance, undersampling, credit risk, numpy · 7 min

Question: A loan-default model was trained on data where non-defaulters were undersampled 1-in-10 to "fix the imbalance". It has a good AUC, and the pricing team now wants to use its scores directly as default probabilities to set interest rates and expected-loss provisions. Is that OK?

Answer:

Trap: "AUC is good, so the probabilities are good."

AUC measures ranking only: the chance that a random defaulter scores above a random non-defaulter. Any monotonic transformation of the scores leaves AUC unchanged, so it says nothing about whether a score of 0.3 means a 30% default risk. That property is calibration, and undersampling breaks it predictably: removing 9 of every 10 negatives multiplies the odds the model learns by about 10. In the snippet, both score sets have AUC 0.764, but the actual default rate is 6.3%, the calibrated model's average prediction is 6.2%, and the rebalanced one's is 33%. Pricing and provisioning from those numbers would overstate expected loss about fivefold.

What I'd do:

  • Correct for the sampling rate if undersampling was used: convert to odds, divide by the sampling factor, convert back (p = p' / (p' + (1 - p') * 10)). Or avoid resampling and use class weights only if you're also going to recalibrate; for probability-sensitive uses, training on the natural distribution is often simplest.
  • Recalibrate on a held-out set with the true class balance (Platt scaling or isotonic regression, e.g. CalibratedClassifierCV), and keep that set out of training.
  • Measure calibration, not just discrimination: reliability curves (predicted vs observed by decile), Brier score or log loss, and expected vs actual defaults per segment. Calibration often differs by segment (salaried vs self-employed, by state), which pricing cares about.
  • Monitor it in production: calibration drifts when the base rate shifts (a credit cycle, a new acquisition channel) even if ranking holds up, and default labels arrive months later, so plan for that delay.

import numpy as np

def auc(y: np.ndarray, score: np.ndarray) -> float:
    """Probability a random positive outranks a random negative (no ties here)."""
    ranks = score.argsort().argsort() + 1
    pos = y == 1
    return (ranks[pos].sum() - pos.sum() * (pos.sum() + 1) / 2) / (pos.sum() * (~pos).sum())

rng = np.random.default_rng(5)
p_true = rng.beta(1, 15, size=50_000)             # default risk, about 6% on average
y = (rng.random(50_000) < p_true).astype(int)

calibrated = p_true
# Same model trained after undersampling negatives 1-in-10: odds inflated 10x.
rebalanced = 10 * p_true / (10 * p_true + (1 - p_true))

print(f"actual default rate: {y.mean():.3f}")
for name, s in [("calibrated", calibrated), ("rebalanced", rebalanced)]:
    print(f"{name:10}  AUC={auc(y, s):.3f}  mean predicted={s.mean():.3f}")

Output:

actual default rate: 0.063
calibrated  AUC=0.764  mean predicted=0.062
rebalanced  AUC=0.764  mean predicted=0.333

Follow-ups:

  1. "If undersampling distorts probabilities, why do people do it?" It cuts training cost on huge datasets and can help some learners pay attention to the rare class. That's fine for ranking uses (who to review first). The mistake is treating the outputs as probabilities without correcting for the sampling rate or recalibrating.
  2. "Isotonic regression or Platt scaling: which would you use?" Platt (a logistic fit on the score) needs little data and assumes a sigmoid-shaped distortion; isotonic is non-parametric and fits any monotonic distortion but overfits with small calibration sets and produces step functions. With tens of thousands of held-out rows, isotonic is usually fine; with a few hundred defaulters, Platt is safer.

Scoring: Strong: Separates ranking (AUC) from calibration; explains the odds inflation from undersampling and the correction; recalibrates on a natural-balance holdout; measures with reliability curves or Brier score per segment and monitors drift with label delay. Acceptable: Knows AUC doesn't measure calibration and suggests recalibrating, but can't say how undersampling distorts the probabilities. Red flag: Uses the scores as probabilities because AUC is high, or proposes SMOTE as the fix for probability accuracy.

SR-TH-EXC-01 · Where a service should catch exceptions

Easy · Theory · Senior (5–8 years) · exceptions, error handling, service design, logging, boundaries · 4 min

Question: In the order service you're reviewing, almost every function has try: ... except Exception as e: logger.error(...); raise. What standard would you set for where exceptions are caught and logged?

Answer:

Catch exceptions in two kinds of places only:

  1. Where you can actually do something about them: retry a transient error, fall back to a cache, translate a low-level error into the module's own error type (raise OrderNotFound(order_id) from exc), or add context. Catch the specific type, close to where it's raised, with the narrowest try body.
  2. At the boundaries of a unit of work: the HTTP request handler / middleware, the queue consumer's per-message loop, the batch job's per-record or per-file loop, the CLI main(). That is where an unexpected exception is turned into a response (500 with an error ID, not a stack trace), a dead-lettered message, a failed-record count or an exit code, and where it is logged once, with traceback and context (request ID, order ID).

Everywhere else, let exceptions propagate. Log-and-reraise at every layer produces the same traceback five times per failure, inflates log volume and alert noise, and makes it hard to count incidents. Context belongs on the exception (add_note, attributes, chaining), not in extra log lines.

Other standards worth writing down:

  • No except Exception: pass without a comment explaining why ignoring is correct; no bare except:.
  • Domain errors that callers handle (InsufficientBalance) are part of the API and documented; programming errors (KeyError, TypeError) are bugs and should reach the boundary.
  • The boundary maps known errors to stable responses (404, 409, 422) and everything else to a generic 500, and reports it to error tracking (Sentry) with the request context.

Follow-ups:

  1. "A Kafka consumer hits an exception on one message. What should the boundary do?" Decide per error type: retry transient errors (with a limit), send poison messages to a dead-letter topic with the error attached, and commit the offset only after the message is handled or parked, so one bad record doesn't block the partition forever and no record is silently skipped.
  2. "Isn't logging at each layer useful for context?" Context is useful; duplicate tracebacks aren't. Add context to the exception as it passes (add_note, raise ... from), and let the single boundary log carry it all. Structured logging with a request ID ties the earlier info-level logs to the error.
  3. "When is catching broadly in the middle of the stack acceptable?" For best-effort side work that must not fail the main operation, e.g. emitting a metric or warming a cache. Even then, log (or count) the failure rather than passing silently.

Scoring: Strong: Catches only where it can act or at unit-of-work boundaries, logs once with context at the boundary, explains the cost of log-and-reraise, and distinguishes domain errors from bugs. Acceptable: Says "don't catch everything" and "log once", but can't name where the boundaries are. Red flag: Recommends wrapping every function in try/except, or swallowing exceptions to keep the service "up".

SR-TH-EXC-02 · return or continue inside finally swallows the exception

Easy & Tricky · Theory · Senior (5–8 years) · exceptions, finally, control flow, code review · 4 min

Question: A settlement job's publish_settlement() has return status inside its finally block, "so it always reports a status". The SFTP upload inside the try has been failing for a week, but the job shows green. Why?

Answer:

Trap: "finally is only for cleanup; it can't change whether the exception propagates."

It can. When an exception is propagating and the finally block executes a return, break or continue, the pending exception is discarded and the jump wins. No error, no traceback, no log. The function returns status (still "unknown" or worse, a stale "ok"), and the scheduler sees success.

The same happens if finally raises a new exception: the original is replaced (though it survives as __context__ in the traceback).

Rules for review:

  • No return, break or continue in finally. Python 3.14 emits a SyntaxWarning for exactly this (PEP 765); until then, linters catch it (Ruff B012, pylint lost-exception).
  • Put the "always report" logic in finally as a side effect (log/metric with the outcome), and keep the return in the normal flow.
  • Track success explicitly (ok = True at the end of try) so finally can report the real outcome without needing to swallow anything.

def publish_settlement(batch_id):
    status = "unknown"
    try:
        raise ConnectionError("bank SFTP unreachable")
    finally:
        print(f"audit: {batch_id} finished with status={status}")
        return status            # meant as "always report status"

print("result:", publish_settlement("ST-2026-10-03"))

def retry_loop():
    for attempt in range(3):
        try:
            raise TimeoutError(f"attempt {attempt}")
        finally:
            continue             # also discards the exception
    return "loop done"

print("result:", retry_loop())

Output:

audit: ST-2026-10-03 finished with status=unknown
result: unknown
result: loop done

Follow-ups:

  1. "How would you rewrite publish_settlement so it always records the outcome but still fails loudly?" Set status = "failed" before try, set status = "ok" as the last line of the try, record status in finally without returning, and return status after the block. The exception propagates; the audit line still runs.
  2. "What happens to a return value in try when finally also returns?" The finally return overrides it. try: return 1 / finally: return 2 returns 2, another reason to keep control flow out of finally.
  3. "Does the same apply to KeyboardInterrupt or SystemExit?" Yes. The swallowing doesn't look at the exception type, so a return in finally can even cancel a Ctrl-C or a sys.exit().

Scoring: Strong: Explains that return/break/continue in finally discards the in-flight exception, gives a rewrite that records the outcome without swallowing, and mentions lint rules or the 3.14 warning. Acceptable: Knows return in finally overrides things but isn't sure it swallows exceptions. Red flag: Believes the exception still propagates, or blames the scheduler.

SR-TH-EXC-03 · Adding context to exceptions with add_note, from and from None

Medium · Theory · Senior (5–8 years) · exceptions, exception chaining, add_note, error messages, security · 5 min

Question: A settlement loader fails with ValueError: invalid literal for int() with base 10: '12,500' and nobody can tell which of 2 lakh rows caused it. Separately, the merchant lookup leaks a raw KeyError traceback to callers. How do you add context, and when do you hide it?

Answer:

Three tools, for three different intents:

  • exc.add_note(...) then bare raise (3.11+): keep the same exception type and traceback, attach context such as file, line number and transaction ID. Callers that catch ValueError still work, and the note prints under the message in tracebacks and format_exception_only. Best for enriching errors as they pass through layers (row loop → file loop → job).
  • raise DomainError(...) from exc: translate a low-level error into your module's public error type, keeping the original as __cause__ ("The above exception was the direct cause of..."). Callers depend on SettlementError, not on whether you used a dict, Redis or SQL underneath.
  • raise DomainError(...) from None: translate and suppress the context display (__suppress_context__ = True). Use it when the original adds nothing (a KeyError from a dict lookup that you've fully explained) or must not be shown. Note it only hides it from the printed traceback; __context__ is still on the object, so logs and error trackers that walk the chain can still see it.

On security: what you hide in Python tracebacks is not the main control. API boundaries should return a stable error code and message, never str(exc) of a database or driver error (it may contain SQL, hostnames or customer data), and log the full chain server-side.

Avoid the older patterns: building a new exception with f"{exc}" pasted into the message (loses type and traceback), or re-raising a bare Exception("failed").

import traceback

class SettlementError(Exception):
    """Public error type of the settlement module."""

def parse_amount(raw):
    return int(raw)

def load_row(lineno, row):
    try:
        return parse_amount(row["amount"])
    except ValueError as exc:
        exc.add_note(f"settlement_2026-10-03.csv line {lineno}, txn {row['txn']}")
        raise

def lookup_merchant(merchants, mid):
    try:
        return merchants[mid]
    except KeyError:
        raise SettlementError(f"unknown merchant {mid}") from None

try:
    load_row(1042, {"txn": "T9", "amount": "12,500"})
except ValueError as exc:
    print("".join(traceback.format_exception_only(exc)), end="")

try:
    lookup_merchant({}, "M-77")
except SettlementError as exc:
    print(repr(exc), "| cause:", exc.__cause__, "| context kept:", repr(exc.__context__),
          "| suppressed:", exc.__suppress_context__)

Output:

ValueError: invalid literal for int() with base 10: '12,500'
settlement_2026-10-03.csv line 1042, txn T9
SettlementError('unknown merchant M-77') | cause: None | context kept: KeyError('M-77') | suppressed: True

Follow-ups:

  1. "Your logging pipeline only records str(exc). Will the note show up?" No. Notes live in exc.__notes__ and are rendered by the traceback machinery, not by str(). Log with logger.exception(...) (full traceback, notes included) or have the structured logger serialise __notes__ explicitly.
  2. "Should you add a note on every layer as the exception propagates?" Only where the layer knows something new (row, file, tenant). Ten notes repeating "failed in loader" are noise. And don't call add_note on a shared or cached exception instance; notes accumulate across raises.
  3. "How do you keep the original exception reachable but show a clean message to the API client?" Translate with from exc internally, map the domain error to a stable error code at the API boundary, include an error/request ID in the response, and log the full chain with that ID server-side.

Scoring: Strong: Picks add_note for enrichment without changing type, from exc for translation, from None for deliberate suppression (knowing __context__ is still kept), and separates traceback hygiene from what the API returns. Acceptable: Knows raise ... from but not add_note, or doesn't know what from None actually does. Red flag: Re-raises a generic Exception(str(e)), or returns raw exception text to API clients.

SR-TH-EXC-04 · assert used for business-rule and permission checks

Medium & Tricky · Theory · Senior (5–8 years) · exceptions, assert, python -O, validation, security · 5 min

Question: A refund approval function starts with assert amount <= limit, "refund exceeds limit", and another endpoint does assert user.is_admin. Tests cover both and pass. Would you approve the PR?

Answer:

Trap: "assert raises AssertionError when the condition is false, so it's a compact validation check."

Only while assertions are enabled. Running Python with -O (or PYTHONOPTIMIZE=1, which some Docker images and deployment templates set) removes assert statements at compile time, so the refund limit and the admin check silently disappear in production while every test (run without -O) passes. It's a security hole that tests can't see.

Even with assertions on, assert is the wrong tool for these checks:

  • It raises a bare AssertionError, which the API boundary can't map to a meaningful response (it becomes a 500, not a 403 or 422), and callers can't catch a specific domain error.
  • It reads as "this can't happen", which misleads reviewers about what is actually a user-controlled input.

Standard to set:

  • Inputs, business rules, permissions: explicit if ...: raise RefundLimitExceeded(...) / raise PermissionDenied(...), or validation at the boundary (Pydantic models, FastAPI dependencies).
  • assert only for internal invariants whose failure means a bug in our code, where removing the check changes nothing for correct programs (e.g. "the list is sorted here").
  • Lint for it: Bandit B101 / Ruff S101 flag assert in non-test code; tests keep using assert freely.

import subprocess
import sys

CHILD = '''
def approve_refund(amount, limit):
    assert amount <= limit, f"refund {amount} exceeds limit {limit}"
    return f"approved {amount}"
print(approve_refund(90_000, 50_000))
'''

for flags in [[], ["-O"]]:
    p = subprocess.run([sys.executable, *flags, "-c", CHILD], capture_output=True, text=True)
    out = p.stdout.strip() or p.stderr.strip().splitlines()[-1]
    print(" ".join(["python", *flags]) + ":", out)

Output:

python: AssertionError: refund 90000 exceeds limit 50000
python -O: approved 90000

Follow-ups:

  1. "Does anyone really run production Python with -O?" It happens: some base images and process managers set PYTHONOPTIMIZE to shrink .pyc files or strip docstrings (-OO), and teams copy those settings without knowing the side effect. You can't rely on it not being set, so security must not depend on asserts.
  2. "What else changes under -O?" __debug__ becomes False, so if __debug__: blocks are removed too, and .pyc files get an opt-1 tag. -OO additionally strips docstrings, which breaks code that reads __doc__ (some CLI and API-doc generators).
  3. "Why didn't the test suite catch this?" Tests run without -O, so the asserts are active and the "limit exceeded" test passes. The difference exists only in the deployed interpreter flags, which is why the rule has to be enforced by review and linting rather than by tests.

Scoring: Strong: Explains that -O/PYTHONOPTIMIZE strips asserts so the checks vanish, that AssertionError maps poorly at API boundaries, sets the rule "assert only for internal invariants", and mentions a lint rule. Acceptable: Knows asserts can be disabled but treats it as theoretical, without proposing a standard. Red flag: Approves it, or thinks assert is equivalent to if not ...: raise.

SR-TH-EXC-05 · Error semantics for a batch job with partial failures

Hard · Theory · Senior (5–8 years) · exceptions, ExceptionGroup, except*, batch processing, data pipelines, dead-letter · 8 min

Question: A nightly job validates and loads 10 lakh UPI transactions from partner files. Today the first bad row raises and the whole run fails; ops wants "load what's valid". How do you design the job's error handling, and where do exception groups fit?

Answer:

First decide the semantics, then the mechanism:

  • Classify failures.
  • Record-level, expected (bad amount, missing VPA): the row is quarantined with a reason; the run continues.
  • Systemic (schema changed, wrong file, DB down, a bug raising TypeError on every row): stop fast. Loading 0 of 10 lakh "valid" rows because every row hits the same bug is not partial success.
  • Thresholds. Fail the run if quarantined rows exceed, say, 0.5% or an absolute count, because a high reject rate usually means a systemic problem disguised as data errors.
  • Atomicity and idempotency. Load valid rows into a staging table or partition keyed by (file, run_id) and publish (swap or merge) only if the run passes thresholds; re-running the same file replaces rather than duplicates.
  • Quarantine is data, not logs. Write rejected rows with error code, message, file and line number to a dead-letter table or file that partners or ops can query and replay. Logs get counts and samples, not 10,000 tracebacks.
  • Exit status and alerting. Distinct outcomes: success, success with rejects (warn), failed (page). The scheduler and dashboards must see the difference.

Where ExceptionGroup fits. Inside the validation step, collect record-level exceptions (domain types like BadAmount, MissingVPA, with structured attributes) and raise them together as one ExceptionGroup. The caller handles each category with except* (quarantine bad amounts, request missing data from the merchant), and anything not matched re-raises automatically, so an unexpected TypeError collected by mistake still fails the run. It's also what asyncio.TaskGroup raises when several concurrent file loads fail. Unexpected exceptions shouldn't be collected at all: catch only your record-level base class per row, and let anything else propagate immediately.

rows = [
    {"txn": "T1", "amount": "250.00", "vpa": "asha@okaxis"},
    {"txn": "T2", "amount": "abc", "vpa": "ravi@ybl"},
    {"txn": "T3", "amount": "120.00", "vpa": ""},
    {"txn": "T4", "amount": "-5", "vpa": "meera@paytm"},
]

class RowError(ValueError):
    def __init__(self, txn, msg):
        super().__init__(f"{txn}: {msg}")
        self.txn = txn

class MissingVPA(RowError): pass
class BadAmount(RowError): pass

def validate(row):
    try:
        amount = float(row["amount"])
    except ValueError:
        raise BadAmount(row["txn"], "amount not numeric") from None
    if amount <= 0:
        raise BadAmount(row["txn"], "amount must be positive")
    if not row["vpa"]:
        raise MissingVPA(row["txn"], "missing VPA")

def validate_batch(rows):
    errors = []
    for row in rows:
        try:
            validate(row)
        except RowError as exc:          # only record-level errors are collected
            errors.append(exc)
    if errors:
        raise ExceptionGroup(f"{len(errors)} of {len(rows)} rows invalid", errors)

try:
    validate_batch(rows)
except* BadAmount as eg:
    print("quarantine:", [e.txn for e in eg.exceptions])
except* MissingVPA as eg:
    print("ask merchant:", [str(e) for e in eg.exceptions])

Output:

quarantine: ['T2', 'T4']
ask merchant: ['T3: missing VPA']

Follow-ups:

  1. "Why not just collect every Exception per row so nothing ever stops the run?" Because a bug or an infrastructure failure then looks like 10 lakh bad rows: the job "succeeds" with everything quarantined, or worse, partially loads before the DB fails. Only catch the error types you have a per-record plan for.
  2. "Holding 10 lakh exceptions in memory to raise a group at the end: is that a problem?" Yes: each exception keeps its traceback and frames alive. For large batches, write rejects to the quarantine sink as you go and keep only counts plus a small sample; raise a group (or a summary error) at the end, not one containing every row.
  3. "A rerun after a fix: how do you avoid loading valid rows twice?" Make the load idempotent: write into a staging partition per (file, run_id) and swap, or MERGE on a natural key such as the transaction ID, so reprocessing replaces rather than appends.

Scoring: Strong: Separates record-level from systemic failures, adds reject thresholds, staged/idempotent publication, a queryable quarantine, distinct exit states, and uses ExceptionGroup/except* only for expected record errors with unmatched errors propagating. Acceptable: Proposes collecting errors and continuing, with a dead-letter file, but no thresholds or systemic-failure detection. Red flag: Wraps each row in except Exception: continue, or only logs rejects.

SR-TH-EXC-06 · Raising one pre-built exception instance in a hot path

Hard & Tricky · Theory · Senior (5–8 years) · exceptions, tracebacks, memory, thread safety, performance · 6 min

Question: To "avoid allocations", a payment client defines TIMEOUT = GatewayTimeout("gateway timeout") at module level and does raise TIMEOUT whenever the gateway times out. During an incident, tracebacks from this error get longer and longer and point at code that wasn't running. Why?

Answer:

Trap: "Raising a pre-built exception is just a cheaper raise; the exception object is immutable."

Exception objects are mutable and carry per-raise state: __traceback__, __context__, __cause__ and __notes__. When you raise an instance that already has a traceback, Python extends its existing __traceback__ rather than starting fresh. So:

  • Tracebacks accumulate. Every raise adds frames to the same object. After thousands of timeouts the traceback holds thousands of entries, most from earlier, unrelated requests, which is the "points at code that wasn't running" symptom.
  • Memory grows. A traceback references frames, and frames reference their local variables (request objects, response bodies). The module-level instance keeps all of that alive for the process lifetime.
  • Cross-request and cross-thread contamination. Concurrent requests mutate the same object: one request's __context__ (whatever it was handling) shows up in another's error report; add_note calls accumulate notes from every request; error trackers group unrelated incidents.

And the saving is negligible: creating a small exception instance is cheap compared to the raise and traceback machinery, which happens anyway.

Fix: raise a new instance each time (raise GatewayTimeout("gateway timeout")), putting per-call details in attributes. If a reusable "template" is needed, use a class or factory function. When re-raising a stored exception on purpose (e.g. a cached failure result), raise a copy or exc.with_traceback(None) and be aware the other attributes are still shared.

import traceback

class GatewayTimeout(Exception):
    pass

TIMEOUT = GatewayTimeout("gateway timeout")      # pre-built "to save allocations"

def call_gateway():
    raise TIMEOUT

def tb_depth(exc):
    return len(traceback.extract_tb(exc.__traceback__))

for attempt in range(1, 4):
    try:
        call_gateway()
    except GatewayTimeout as exc:
        print(f"attempt {attempt}: same object={exc is TIMEOUT}, traceback frames={tb_depth(exc)}")

def call_gateway_fixed():
    raise GatewayTimeout("gateway timeout")

for attempt in range(1, 4):
    try:
        call_gateway_fixed()
    except GatewayTimeout as exc:
        print(f"fixed attempt {attempt}: traceback frames={tb_depth(exc)}")

Output:

attempt 1: same object=True, traceback frames=2
attempt 2: same object=True, traceback frames=4
attempt 3: same object=True, traceback frames=6
fixed attempt 1: traceback frames=2
fixed attempt 2: traceback frames=2
fixed attempt 3: traceback frames=2

Follow-ups:

  1. "Where else does the "same exception object raised many times" pattern appear without anyone writing a module-level constant?" Cached failures: a memoised function that stores and re-raises an exception, or concurrent.futures.Future.result(), which re-raises the stored instance to every caller, so its traceback grows with each call. asyncio's Future.result() resets the traceback to the original one on every call to avoid exactly this.
  2. "How would you find this during the incident?" The traceback length itself is a clue; len(traceback.extract_tb(exc.__traceback__)) growing over time, or exc is module.TIMEOUT in a debugger. In code search, look for raise SOME_CONSTANT where the name is a module-level instance.
  3. "Is a pre-built exception ever reasonable?" Rarely. Even for exceptions used purely as internal control flow that never escape, the saving is tiny compared with the cost of the raise itself; in application code the shared-state risks outweigh it.

Scoring: Strong: Explains that exceptions are mutable, that re-raising extends __traceback__, the memory retention through frames, cross-request contamination of context and notes, and fixes by raising fresh instances. Acceptable: Recognises that sharing an exception object across requests is unsafe but can't explain the growing traceback. Red flag: Thinks the traceback is rebuilt from scratch on each raise, or defends the constant as an optimisation.

SR-TH-FN-01 · Designing a function signature that 40 teams can call for years

Easy · Theory · Senior (5–8 years) · API design, function signatures, keyword-only, positional-only, deprecation, backward compatibility · 5 min

Question: You own send_notification(...) in an internal SDK used by 40 services. What rules do you follow when designing its signature so it can evolve without breaking callers?

Answer:

The signature is the contract, so design it for change:

  • Few positional parameters. Only the obvious, primary ones: send_notification(user_id, template, /, *, channel="sms", ...). Mark them positional-only (/) if you might rename them, since callers can't depend on the names.
  • Everything optional is keyword-only (after *). You can add, reorder or deprecate options freely, and calls read clearly (priority="high" rather than a bare True, 3).
  • No boolean flag parameters that switch behaviour. Use an enum or Literal["sms", "email"], or a separate function.
  • Add new parameters only as keyword-only with defaults that preserve old behaviour. Never insert a positional parameter in the middle.
  • Avoid **kwargs catch-alls in public functions. They swallow typos and hide the contract from type checkers (see SR-TH-FN-02).
  • Accept abstract types, return concrete ones: take Iterable[str]/Mapping, return a dataclass or model rather than a tuple, so fields can be added later without breaking unpacking.
  • Deprecate, don't break: keep the old name or parameter working, emit warnings.warn(..., DeprecationWarning, stacklevel=2) (or use @warnings.deprecated from 3.13, available in typing_extensions for older versions), document a removal version and follow semantic versioning.

Then back it with types (py.typed shipped in the package) and a contract test suite run against the public API, so a breaking change fails CI before it reaches the 40 callers.

Follow-ups:

  1. "Why stacklevel=2 on the deprecation warning?" So the warning points at the caller's line, not at your library's internals. That's what lets each of the 40 teams find the call site they need to change.
  2. "A caller needs one extra option urgently. Do you add a parameter or a new function?" A keyword-only parameter with a backward-compatible default if it fits the function's single purpose. A new function if it changes what the function fundamentally does. Signatures that accumulate many unrelated options are a sign the function should be split or take a config object.

Scoring: Strong: Positional-only for core args, keyword-only for options, no boolean switches or **kwargs catch-alls, extensible return types, deprecation with stacklevel and semver, enforced by types and CI. Acceptable: Uses keyword-only arguments and defaults sensibly, but has no deprecation or versioning strategy. Red flag: Adds parameters positionally as needs arise, or relies on **kwargs for flexibility.

SR-TH-FN-02 · A `**kwargs` wrapper turns a typo into a silent default

Easy & Tricky · Theory · Senior (5–8 years) · kwargs, API design, wrappers, typing, TypedDict, Unpack · 4 min

Question: A shared HTTP helper is def fetch(url, **opts) and forwards opts.get("timeout", 30) and opts.get("retries", 2) to the real request function. A service calls fetch(kyc_url, timout=2) for a latency-sensitive KYC check. What happens?

Answer:

Trap: "Python raises TypeError for an unexpected keyword argument, so the typo would be caught."

Only functions with a fixed parameter list reject unknown keywords. **opts accepts anything, and the wrapper only picks out the keys it knows. timout=2 lands in opts, nobody reads it, and the request runs with the default 30 s timeout. The KYC call that should fail fast after 2 s can now hold a worker for 30 s, which under load becomes thread or worker-pool exhaustion: an outage, not a typo. Calling the inner function directly with the same typo raises immediately, which shows the protection the wrapper removed.

Fixes, best first:

  • Explicit keyword-only parameters on the wrapper: def fetch(url, *, timeout=30, retries=2). Typos raise, IDEs autocomplete, and type checkers verify calls.
  • If the wrapper must forward to something with many options, forward **opts wholesale to a callee with a fixed signature, so the callee validates. Or check opts.keys() - ALLOWED and raise.
  • For typed pass-through, annotate with a TypedDict and **kwargs: Unpack[FetchOptions] (PEP 692) so mypy or pyright flags timout.

def _request(url, timeout=30, retries=2):
    return f"GET {url} timeout={timeout} retries={retries}"


def fetch(url, **opts):
    return _request(url, timeout=opts.get("timeout", 30), retries=opts.get("retries", 2))


print(fetch("https://kyc.internal/pan", timout=2))
print(_request("https://kyc.internal/pan", timout=2))

Output:

GET https://kyc.internal/pan timeout=30 retries=2
TypeError: _request() got an unexpected keyword argument 'timout'

Follow-ups:

  1. "When is a **kwargs parameter the right design?" In true pass-through layers where the wrapper doesn't interpret the options (decorators, thin adapters) and the callee validates them, and in functions that accept arbitrary labelled data (log.info(msg, **fields)). In both cases the unknown keys reach something that uses or checks them.
  2. "How would you roll out the explicit-signature fix without breaking callers already passing other keys?" First add explicit parameters and keep **opts temporarily, logging or warning (DeprecationWarning) on any unknown key with its call site. Fix the callers the warnings reveal (several will be real bugs like this one), then remove **opts in the next major version.

Scoring: Strong: Explains that **kwargs absorbs typos, quantifies the operational impact (30 s vs 2 s timeout under load), and fixes with keyword-only parameters or forwarding plus validation, mentioning TypedDict/Unpack. Acceptable: Sees the typo is ignored and switches to explicit parameters. Red flag: Expects a TypeError, or suggests documenting the correct spelling as the fix.

SR-TH-FN-03 · How frameworks read your function signatures, and how decorators break them

Medium · Theory · Senior (5–8 years) · inspect.signature, Signature.bind, FastAPI, pytest, decorators, functools.wraps, introspection · 6 min

Question: FastAPI builds request parsing from your endpoint's parameters, and pytest injects fixtures by parameter name. How do they get that information? What breaks when a team member wraps endpoints in a decorator, and how is Signature.bind useful in your own code?

Answer:

These frameworks call inspect.signature(fn), which returns the parameters (name, kind, default, annotation), and usually typing.get_type_hints(fn) to resolve annotations into real types. FastAPI turns each parameter into a path, query or body field, or a dependency. pytest matches parameter names to fixtures. Click and Typer build CLI options the same way, and pydantic's validate_call builds a validator from it.

inspect.signature follows __wrapped__, which functools.wraps sets, and honours an explicit __signature__. So:

  • A decorator without functools.wraps exposes the wrapper's (*args, **kwargs). FastAPI then sees no parameters, or treats args and kwargs as query parameters, and pytest can't inject fixtures. It fails at startup or behaves strangely, far from the decorator.
  • A decorator with wraps that changes the parameters (injects a user argument) must also set wrapper.__signature__ to the new signature, or the framework passes arguments the wrapper doesn't expect.
  • With from __future__ import annotations, annotations are strings. If a type is imported only under if TYPE_CHECKING:, get_type_hints raises NameError when the framework resolves it at run time.

Signature.bind(*args, **kwargs) applies Python's argument-binding rules without calling the function. It returns a BoundArguments mapping (after apply_defaults()), or raises the same TypeError a call would. Uses: normalising calls into a stable cache key (so f(1, b=2) and f(1, 2) hit the same entry), audit logging with parameter names, and validating arguments in a decorator before doing expensive work.

import functools
import inspect


def audited(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper


def bare(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper


def create_order(customer_id: str, amount_paise: int, *, notes: str = "") -> dict:
    return {}


print(inspect.signature(audited(create_order)))
print(inspect.signature(bare(create_order)))

sig = inspect.signature(create_order)
bound = sig.bind("C-42", 99_900)
bound.apply_defaults()
print(bound.arguments)
try:
    sig.bind("C-42", 99_900, "rush")
except TypeError as e:
    print("TypeError:", e)

Output:

(customer_id: str, amount_paise: int, *, notes: str = '') -> dict
(*args, **kwargs)
{'customer_id': 'C-42', 'amount_paise': 99900, 'notes': ''}
TypeError: too many positional arguments

Follow-ups:

  1. "How do you make a decorator that adds a keyword-only request_id parameter work with FastAPI?" Build the new signature with sig.replace(parameters=[...existing, inspect.Parameter("request_id", KEYWORD_ONLY, default=None)]), assign it to wrapper.__signature__, and in the wrapper pop request_id before calling the original. For type checkers, use Concatenate/ParamSpec.
  2. "Is signature introspection slow enough to matter?" It's done once at startup or registration by frameworks, so no. Calling inspect.signature on every request in your own hot path is measurable; compute it once at decoration time and reuse it.

Scoring: Strong: Explains inspect.signature and get_type_hints, __wrapped__/__signature__, concrete breakages (FastAPI params, pytest fixtures, TYPE_CHECKING imports), and practical Signature.bind uses. Acceptable: Knows to use functools.wraps because frameworks inspect signatures, without the deeper mechanics. Red flag: Thinks FastAPI parses the source code or docstrings, or doesn't connect missing wraps to framework failures.

SR-TH-FN-04 · Two `**` unpackings in one call raise on a duplicate key; a dict merge doesn't

Medium & Tricky · Theory · Senior (5–8 years) · argument unpacking, kwargs, PEP 448, dict merge, wrappers · 4 min

Question: A payments wrapper calls charge(order_id, **defaults, **overrides) where defaults = {"currency": "INR", "idempotency_key": None} and overrides comes from the caller. It passed review because "later values win, like {**a, **b}". A caller passes currency="USD". What happens?

Answer:

Trap: "The later mapping wins, just as it does in a dict display."

The two syntaxes look alike but have different rules:

  • In a dict display {**defaults, **overrides}, duplicate keys are allowed and the last one wins.
  • In a function call, every keyword must bind exactly once. Two ** unpackings that supply the same key raise TypeError: ... got multiple values for keyword argument 'currency'. The same error occurs for a positional and a keyword argument that target one parameter, e.g. f(1, a=2) when a is the first parameter.

So the wrapper works for every caller that doesn't override a default, and fails at run time only when someone does. That's exactly the kind of path that is rarely tested and appears in production with the first USD order.

The call-site rule is deliberate (PEP 448): silently letting one source override another inside a call would hide bugs. The fix is to decide precedence explicitly and merge first: params = {**defaults, **overrides}; charge(order_id, **params). For anything payment-related, also validate the merged keys against what charge accepts, so a misspelt override fails loudly.

def charge(order_id, *, amount, currency="INR", idempotency_key=None):
    return f"{order_id} {amount} {currency} key={idempotency_key}"


defaults = {"currency": "INR", "idempotency_key": None}
overrides = {"currency": "USD", "amount": 4999}

print({**defaults, **overrides})
print(charge("A-101", **{**defaults, **overrides}))
print(charge("A-101", **defaults, **overrides))

Output:

{'currency': 'USD', 'idempotency_key': None, 'amount': 4999}
A-101 4999 USD key=None
TypeError: __main__.charge() got multiple values for keyword argument 'currency'

Follow-ups:

  1. "Why did the error message say __main__.charge() rather than just charge()?" For this error CPython reports the function's qualified name including the module. It's a reminder not to assert on exact error message text in tests. Assert on the exception type and maybe a stable fragment such as the parameter name.
  2. "How would you make the wrapper's merge order obvious to readers and type checkers?" Give the wrapper explicit keyword-only parameters with defaults instead of a defaults dict, so Python's own binding gives "caller wins" semantics. Or use a frozen dataclass of options plus dataclasses.replace(defaults, **overrides), which validates field names.

Scoring: Strong: Distinguishes dict-display merge (last wins) from call-site binding (duplicate is an error), explains why it surfaces only for some callers, fixes with an explicit merge and validation. Acceptable: Gets the TypeError and fixes with a pre-merge. Red flag: Predicts USD succeeds, or "fixes" it by catching TypeError and retrying without the override.

SR-TH-FN-05 · singledispatch, @overload or an isinstance chain for type-dependent behaviour

Hard · Theory · Senior (5–8 years) · functools.singledispatch, typing.overload, library design, extensibility, serialisation · 7 min

Question: Your shared library has a to_json(value) serialiser with a growing if isinstance(...) chain, and other teams keep asking you to add their types. Compare an isinstance chain, functools.singledispatch and typing.overload. Which would you use here, and what pitfalls would you watch for?

Answer:

They solve different problems:

  • isinstance chain: closed and central. All types are known to one module, and the order of the checks is the priority. It's fine for a few stable types, but every new type means changing your library, and order bugs creep in (bool is a subclass of int, datetime is a subclass of date).
  • functools.singledispatch: open, runtime dispatch on the class of the first argument, resolved through the MRO (so the most specific registered class wins, regardless of registration order) and cached per type. Other teams can call to_json.register for their own types in their own packages without touching yours. singledispatchmethod does the same for methods (dispatching on the first argument after self).
  • typing.overload: static only. It tells type checkers how the return type depends on the argument types (str in, bytes out). It has no run-time effect; you still write one implementation that handles every case.

For this serialiser, singledispatch is the right fit, with @overload optional on top if callers need precise return types. Pitfalls:

  • Registration happens at import time. If the module that registers Money is never imported, dispatch silently falls back to the default. Document where registrations live, or use a plugin entry point that imports them.
  • It dispatches on one argument only. Multi-argument dispatch needs a different design.
  • Register bool separately if it must not be treated as int, and remember subclasses (datetime) inherit a parent's handler.
  • Make the default implementation raise a clear TypeError rather than guessing (for example by calling str(value)).

An alternative worth naming is a protocol: types that implement a __json__() method serialise themselves. That puts the knowledge with the type's owner and needs no registry, but it only works for types you or the other team control.

from datetime import date, datetime
from decimal import Decimal
from functools import singledispatch


@singledispatch
def to_json(value):
    raise TypeError(f"cannot serialise {type(value).__name__}")


@to_json.register
def _(value: Decimal):
    return str(value)                 # keep paise exact; never via float


@to_json.register
def _(value: date):
    return value.isoformat()


print(to_json(Decimal("1499.50")), to_json(date(2026, 1, 26)))
print(to_json(datetime(2026, 1, 26, 9, 30)))   # subclass of date -> date handler
try:
    to_json(1.5)
except TypeError as e:
    print("TypeError:", e)

Output:

1499.50 2026-01-26
2026-01-26T09:30:00
TypeError: cannot serialise float

Follow-ups:

  1. "Can you register a handler for an ABC or a union of types?" Yes. Registering for an ABC like collections.abc.Mapping makes every registered or virtual subclass dispatch to it. Since 3.11 register also accepts union annotations (int | float). Concrete registrations still win over the ABC for their specific types.
  2. "How would you test that other teams' registrations are actually loaded in production?" Add a startup self-check that serialises one sample of each expected type, or inspect to_json.registry.keys() against an expected list. Fail fast at boot rather than producing a TypeError on the first real request.

Scoring: Strong: Clearly distinguishes runtime open dispatch, static overloads and closed chains; picks singledispatch with reasons; covers import-time registration, single-argument dispatch, MRO and subclass gotchas; mentions the protocol alternative. Acceptable: Knows singledispatch and when to use it, but is unclear that @overload has no run-time effect. Red flag: Thinks @overload creates run-time dispatch like in Java or C++, or keeps growing the isinstance chain without seeing the extensibility problem.

SR-TH-FN-06 · No tail-call optimisation, and recursion on untrusted nesting depth

Hard & Tricky · Theory · Senior (5–8 years) · recursion, RecursionError, tail calls, security, denial of service, input validation · 5 min

Question: A webhook ingestion service flattens incoming JSON with a recursive flatten(d, prefix) function. The author says it's safe because "the recursive call is the last thing in the loop, and Python optimises that". A partner (or an attacker) sends a document nested 3,000 levels deep. What happens, and how do you design this properly?

Answer:

Trap: "Python optimises tail calls, or grows the stack as needed, so depth doesn't matter."

CPython has no tail-call optimisation, by design: Guido rejected it to keep complete tracebacks. (The call here isn't even in tail position, since its result is passed to update.) Every nested call is a new frame, and the interpreter enforces sys.getrecursionlimit(), 1000 by default. At around 1,000 levels flatten raises RecursionError. Note that json.loads itself accepted the 3,000-deep document; its C parser has its own, much higher limit.

Why it matters at senior level:

  • It's an input-controlled failure mode (denial of service). Whoever controls the payload controls your recursion depth. Worse, a broad except Exception around the handler turns it into a silent drop or a retry loop.
  • Raising the limit is not a fix. sys.setrecursionlimit(100_000) is process-wide, deep frames consume memory, and recursion through C code (some builtins, json encoding, repr of nested objects) can still overflow the C stack and crash the process instead of raising.

Proper design:

  • Validate structure at the boundary. Reject payloads deeper than the contract allows (say 32 levels) and bigger than a size limit, with a clear 4xx error and a metric.
  • Use iteration with an explicit stack for tree walks over data you don't control. Its memory is bounded by your validation, not by interpreter limits.
  • Keep recursion for structures whose depth you control and know is small, such as your own config trees or ASTs from trusted code.

import json
import sys


def flatten(d, prefix=""):
    out = {}
    for k, v in d.items():
        if isinstance(v, dict):
            out.update(flatten(v, f"{prefix}{k}."))
        else:
            out[f"{prefix}{k}"] = v
    return out


def flatten_iter(d):
    out, stack = {}, [("", d)]
    while stack:
        prefix, node = stack.pop()
        for k, v in node.items():
            if isinstance(v, dict):
                stack.append((f"{prefix}{k}.", v))
            else:
                out[f"{prefix}{k}"] = v
    return out


payload = '{"a": ' * 3000 + "1" + "}" * 3000     # attacker-controlled depth
doc = json.loads(payload)
print(sys.getrecursionlimit())
try:
    flatten(doc)
except RecursionError:
    print("RecursionError")
print(len(flatten_iter(doc)))

Output:

1000
RecursionError
1

Follow-ups:

  1. "How would you enforce a maximum depth cheaply before processing?" Validate with the schema layer if it supports depth (or recursive pydantic models with a max depth check), or do a single iterative pass that tracks depth and aborts past the limit. Combine with a request-body size limit at the web server or gateway, so huge payloads never reach Python.
  2. "Is the iterative version's output identical to the recursive one?" The key-value pairs are identical, but the insertion order of keys can differ because a stack visits nested dicts in a different order. If order matters (e.g. CSV columns), sort the keys or use a deque to control traversal order. That's the kind of detail to note in review.

Scoring: Strong: States there's no TCO and the default limit, frames it as input-controlled DoS, rejects raising the limit as a fix, and proposes boundary validation plus iterative traversal. Acceptable: Knows about RecursionError and rewrites iteratively, without the security framing. Red flag: Believes Python optimises tail calls, or proposes sys.setrecursionlimit as the solution.

SR-TH-FS-01 · Keeping a TypeScript frontend in sync with a FastAPI backend

Easy · Theory · Senior (5–8 years) · OpenAPI, FastAPI, code generation, API contracts, JSON serialisation, CI · 6 min

Question: In your team's React + FastAPI app, frontend bugs keep coming from hand-written TypeScript types that drift from the backend (a renamed field, an amount that's a string, not a number). How do you keep both sides in sync, as a standard?

Answer:

Make the backend's OpenAPI schema the single source of truth and generate the frontend types and client from it, with CI enforcing that they match.

Backend side:

  • Declare response_model (or return annotations) on every endpoint and use Pydantic models for every body, so the schema describes reality; an endpoint returning a bare dict shows up as "any".
  • Give operations stable operation_ids (FastAPI's defaults look like read_order_orders__order_id__get; set generate_unique_id_function), because generated function names derive from them.
  • Know how Pydantic serialises, since that's what the browser actually gets. FastAPI publishes separate input and output schemas when they differ: in the snippet, Decimal is accepted as a number or string on input but sent as a string ("1499.50"); datetimes are ISO strings; and an int ID above 2^53 loses precision in JavaScript's JSON.parse (it's a float64), so large IDs should be sent as strings.

Generation and CI:

  • Generate TypeScript types or a typed client (openapi-typescript, orval, openapi-generator) from the exported schema, in a monorepo package or a published one.
  • In CI on backend PRs: export openapi.json, regenerate, and fail if the generated code differs from what's committed; run a breaking-change diff (e.g. oasdiff) against the deployed schema, so a rename can't merge silently.
  • Optionally validate responses at runtime in the frontend (zod schemas generated from the same OpenAPI) in development builds, to catch anything the static types miss.

Process: breaking changes follow expand/contract (add the new field, migrate the frontend, then remove the old one), because deployed browsers keep running the old frontend for a while after a release.

from datetime import datetime, timezone
from decimal import Decimal
from pydantic import BaseModel

class Order(BaseModel):
    id: int
    amount: Decimal
    created_at: datetime

o = Order(id=2**53 + 1, amount=Decimal("1499.50"),
          created_at=datetime(2026, 10, 4, 9, 30, tzinfo=timezone.utc))
print(o.model_dump_json())

schema_in = Order.model_json_schema(mode="validation")["properties"]["amount"]
schema_out = Order.model_json_schema(mode="serialization")["properties"]["amount"]
print("amount accepted as:", [s["type"] for s in schema_in["anyOf"]], "| sent as:", schema_out["type"])

# A JavaScript Number is a float64: this is what JSON.parse does to that id.
print(int(float(o.id)), int(float(o.id)) == o.id)

Output:

{"id":9007199254740993,"amount":"1499.50","created_at":"2026-10-04T09:30:00Z"}
amount accepted as: ['number', 'string'] | sent as: string
9007199254740992 False

Follow-ups:

  1. "Should the generated client be committed to the repo or generated at build time?" Either works if CI enforces consistency. Committing makes diffs reviewable (a reviewer sees exactly what changed for the frontend) and keeps builds simple; generating at build time avoids stale files but hides changes. Many teams commit and add a CI check that regeneration produces no diff.
  2. "Why might you prefer strings for money in the API even if the frontend only displays it?" A JSON number is parsed into a float64 by JavaScript, so arithmetic or re-serialisation can introduce binary rounding (0.1 + 0.2). A decimal string, or integer paise, preserves the exact value; the frontend formats it with Intl.NumberFormat('en-IN', {style: 'currency', currency: 'INR'}) for display.

Scoring: Strong: Makes OpenAPI the source of truth with accurate response models and stable operation IDs; generates the client and enforces no-diff plus breaking-change checks in CI; understands Decimal, datetime and large-int serialisation; uses expand/contract for changes. Acceptable: Suggests generating TypeScript types from FastAPI's OpenAPI, without CI enforcement or serialisation details. Red flag: Relies on keeping hand-written types in sync through code review, or shares Python types by copying them manually.

SR-TH-FS-02 · Storing the access token in localStorage "to avoid CSRF"

Easy & Tricky · Theory · Senior (5–8 years) · authentication, XSS, CSRF, cookies, localStorage, backend-for-frontend · 6 min

Question: A teammate proposes that the SPA keep its 30-day JWT in localStorage and send it as a bearer header, arguing it's more secure than cookies because it makes CSRF impossible. You're reviewing the design. What do you say?

Answer:

Trap: "Bearer tokens in localStorage are immune to CSRF, so they're the secure option."

The CSRF point is true: the browser doesn't attach a localStorage token automatically, so a cross-site form can't use it. But it trades CSRF for a worse failure under XSS: any script running on the page can read localStorage and send the token to an attacker. That includes injected script through a rendering bug, a compromised npm dependency, or a third-party analytics or chat widget. The stolen token works from anywhere, for 30 days, after the user closes the tab, and usually can't be revoked if it's a self-contained JWT.

With an HttpOnly cookie, script can't read the credential. XSS is still serious (the attacker's script can make requests as the user while the page is open), but it can't exfiltrate a long-lived credential.

What I'd recommend:

  • Session in an HttpOnly; Secure; SameSite=Lax (or Strict) cookie, ideally a backend-for-frontend: the Python backend holds the OAuth tokens server-side and the browser only has a session cookie. Short idle timeout, server-side revocation on logout and password change.
  • CSRF defences still apply to cookies: SameSite as the baseline, plus a CSRF token or a check of the Origin header on state-changing requests; never change state on GET.
  • Reduce XSS itself: a strict Content-Security-Policy, no dangerouslySetInnerHTML/innerHTML with user data, subresource integrity or self-hosting for third-party scripts.
  • If bearer tokens are required (e.g. calling several APIs directly), keep a short-lived access token in memory only, refreshed via an HttpOnly refresh cookie with rotation, and never put tokens in URLs.

Follow-ups:

  1. "With SameSite=Lax, is a CSRF token still necessary?" Lax blocks cookies on cross-site subresource and POST requests, but still sends them on top-level GET navigations, so any state-changing GET is exposed. 'Same-site' also means same registrable domain, so a compromised or user-content sibling subdomain (uploads.example.in) counts as same-site. Older clients may not enforce it. So: SameSite as a baseline, plus Origin checks or tokens for state-changing endpoints.
  2. "Why is a 30-day self-contained JWT a problem even when stored safely?" It can't be revoked before it expires without a server-side denylist, which removes the 'stateless' benefit. Use short-lived access tokens (minutes) with refresh tokens that the server can revoke and rotate, or opaque session IDs checked against a store.

Scoring: Strong: Concedes the CSRF point but explains token theft via XSS (including supply-chain scripts) and its persistence; recommends HttpOnly Secure SameSite cookies or a BFF with CSRF protection, CSP, short-lived tokens and revocation; knows SameSite's limits. Acceptable: Says localStorage is readable by XSS and recommends HttpOnly cookies, but forgets that cookies then need CSRF protection. Red flag: Agrees localStorage is safer, or says HTTPS alone protects the token.

SR-TH-FS-03 · A "download statement" button for 2 million rows

Medium · Theory · Senior (5–8 years) · background jobs, 202 Accepted, object storage, presigned URLs, streaming, CSV injection · 7 min

Question: Merchants want a "Download transactions" button in the dashboard that exports up to 2 million rows as CSV for any date range. The first version builds the CSV inside the request handler; it times out at the load balancer and sometimes OOM-kills the pod. Design it properly, front to back.

Answer:

A big export is a job, not a request. The design:

API:

  • POST /exports with the filters returns 202 Accepted with a job ID (and a Location header for the status URL). Make it idempotent: the same merchant asking for the same range while a job is running gets the existing job, and a per-merchant concurrency limit stops someone queueing 50 exports.
  • GET /exports/{id} returns status, progress and, when done, a short-lived presigned URL to the file in object storage. Authorise every lookup against the job's owner; job IDs alone aren't credentials.

Worker (Celery, RQ, arq, or a managed queue):

  • Stream from the database with a server-side cursor (yield_per / stream_results) in pages, never fetchall(); take a consistent snapshot (one repeatable-read transaction, or filter by an "as of" timestamp) so rows don't shift mid-export.
  • Write incrementally to object storage (multipart upload), gzip-compressed, with constant memory regardless of row count. Update progress every N rows.
  • Make the job retryable: a retry overwrites the same object key, and the job is marked done only after the upload completes.

Frontend: show "preparing your export" with progress, poll the status endpoint with backoff (or receive an SSE/WebSocket event or an email/notification when ready), and let users leave the page; list recent exports so they can come back.

Details that bite in production:

  • CSV/formula injection: a cell starting with =, +, - or @ (a customer name like =HYPERLINK(...)) executes in Excel; prefix such values with ' in text columns (not in numeric columns, where -250.00 is a legitimate refund).
  • Excel compatibility: UTF-8 with BOM (utf-8-sig) so ₹ and Indian-language names display correctly; timestamps in IST, labelled; amounts as plain numbers in rupees with two decimals.
  • Data protection: the file contains PII, so a lifecycle rule deletes it after, say, 7 days, presigned URLs expire in minutes, and downloads are audit-logged.
  • For small ranges, a streaming response directly from the endpoint (a generator yielding CSV chunks) is fine and simpler; switch to the job path above a size threshold.

Follow-ups:

  1. "Why not just raise the load balancer timeout to 10 minutes and stream the response?" Streaming fixes memory, but a 10-minute request still ties up a worker and a DB connection, breaks on any network blip or deploy with no way to resume, and every proxy in the path needs the longer timeout. Jobs survive disconnects, can be retried, rate-limited and observed, and let the user leave the page.
  2. "How do you stop exports from slowing down the main database?" Run them against a read replica or the warehouse rather than the primary, limit concurrent export jobs globally and per merchant (queue priority), make sure the filter uses an index (merchant ID plus date), and page by keyset rather than OFFSET.

Scoring: Strong: Turns the export into an idempotent job with 202 and a status endpoint; streams from a server-side cursor to object storage with constant memory and a consistent snapshot; presigned short-lived downloads with authorisation; handles CSV injection, Excel encoding, PII retention and replica usage. Acceptable: Moves the export to a background job with polling and a download link, but misses streaming, consistency or security details. Red flag: Raises timeouts and memory limits, or builds the whole file in memory in the worker instead.

SR-TH-FS-04 · Two admins edit the same product and one change vanishes

Medium & Tricky · Theory · Senior (5–8 years) · optimistic concurrency, ETag, If-Match, 412 Precondition Failed, lost update, FastAPI · 7 min

Question: In the catalogue admin UI, Asha and Ravi open the same product's edit form. Asha saves a price of ₹849; a minute later Ravi saves ₹999 from his stale form, and Asha's change is silently overwritten. The backend wraps each save in a database transaction, so the team believes this can't be a concurrency bug. How do you fix it end to end?

Answer:

Trap: "Each update runs in a transaction, so updates can't overwrite each other."

A transaction makes one request atomic. This race spans two requests per user (load the form, later save it) separated by minutes of human think time; no database transaction is open while the form sits in the browser. Each save is individually correct, and the last one wins. This is the lost-update problem at the HTTP level, and locking a row for the minutes someone has a form open isn't viable.

The standard fix is optimistic concurrency with a version:

  1. Store a version (or updated_at) on the row. GET returns it as an ETag header (or in the body).
  2. The UI sends it back on save as If-Match.
  3. The server updates only if the version still matches, atomically: UPDATE products SET price = :p, version = version + 1 WHERE id = :id AND version = :v. If zero rows changed, return 412 Precondition Failed. If the client sent no If-Match at all, 428 Precondition Required stops old clients from bypassing the check.
  4. The UI handles 412 as a product feature: show "Asha changed this product 1 minute ago", the current values next to yours, and let the user reapply or merge, rather than a generic error toast.

In the snippet, Asha's save succeeds and bumps the ETag to "2"; Ravi's save with the stale "1" gets 412; a save with no If-Match gets 428; the stored price stays ₹849.

The comparison and the write must be one atomic step (the conditional UPDATE, or SELECT ... FOR UPDATE then compare); checking the version in Python and then updating in a separate statement reintroduces the race between concurrent requests.

from fastapi import FastAPI, Header, HTTPException, Response
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()
PRODUCTS = {42: {"name": "Basmati rice 5 kg", "price": 899, "version": 1}}

class PriceUpdate(BaseModel):
    price: int

@app.get("/products/{pid}")
def get_product(pid: int, response: Response):
    p = PRODUCTS[pid]
    response.headers["ETag"] = f'"{p["version"]}"'
    return {"name": p["name"], "price": p["price"]}

@app.patch("/products/{pid}")
def update_price(pid: int, body: PriceUpdate, response: Response,
                 if_match: str | None = Header(default=None)):
    p = PRODUCTS[pid]
    if if_match is None:
        raise HTTPException(428, "If-Match required")
    if if_match != f'"{p["version"]}"':
        raise HTTPException(412, "Product changed since you loaded it")
    # In SQL: UPDATE ... SET price=?, version=version+1 WHERE id=? AND version=?
    p["price"], p["version"] = body.price, p["version"] + 1
    response.headers["ETag"] = f'"{p["version"]}"'
    return {"price": p["price"]}

client = TestClient(app)
etag_asha = client.get("/products/42").headers["etag"]     # Asha opens the edit form
etag_ravi = client.get("/products/42").headers["etag"]     # Ravi opens it too

r1 = client.patch("/products/42", json={"price": 849}, headers={"If-Match": etag_asha})
r2 = client.patch("/products/42", json={"price": 999}, headers={"If-Match": etag_ravi})
r3 = client.patch("/products/42", json={"price": 999})
print("Asha:", r1.status_code, r1.json(), r1.headers["etag"])
print("Ravi:", r2.status_code, r2.json())
print("No If-Match:", r3.status_code)
print("Stored price:", PRODUCTS[42]["price"])

Output:

Asha: 200 {'price': 849} "2"
Ravi: 412 {'detail': 'Product changed since you loaded it'}
No If-Match: 428
Stored price: 849

Follow-ups:

  1. "Ravi only changed the description and Asha only changed the price. Should his save really be rejected?" With a whole-resource version, yes, which can be annoying on busy records. Options: send only changed fields (PATCH) and check versions per field group, or do a three-way merge on the server (original, theirs, mine) and reject only real conflicts. Choose based on how often conflicts happen; for most admin screens, a clear 412 with a reload-and-reapply flow is enough.
  2. "Can the ETag be a hash of the response body instead of a version column?" For caching (If-None-Match with 304), a content hash is fine. For If-Match on writes, the check must happen atomically in the database, so a version column (or the row's updated_at with enough precision) is more practical than recomputing a hash inside the update.

Scoring: Strong: Explains that per-request transactions can't cover read-think-write across requests; implements version plus ETag/If-Match with an atomic conditional update, 412 and 428; designs the UI conflict flow. Acceptable: Proposes a version column checked on save, but checks it non-atomically or ignores what the user sees on conflict. Red flag: Proposes pessimistic row locks held while the form is open, or says last-write-wins is acceptable without asking the business.

SR-TH-FS-05 · HTTP caching policy for a site with logged-in users

Hard · Theory · Senior (5–8 years) · HTTP caching, Cache-Control, CDN, ETag, Vary, static assets, privacy · 8 min

Question: Your e-commerce site (Python backend, SPA frontend, a CDN in front) is slow for users outside Mumbai, and someone suggests turning on "cache everything" at the CDN. Before that, you're asked to define a caching policy for the whole site. What is it, and what can go badly wrong?

Answer:

Caching is set per class of response, explicitly, by the origin; the CDN's job is to obey Cache-Control, not to guess.

Response Policy Why
Static assets with content hashes (app.3f9c1a.js, images) Cache-Control: public, max-age=31536000, immutable The name changes when content changes, so cache forever at the browser and CDN
index.html / SSR pages for anonymous users no-cache (store, but revalidate each time with ETag), or a short s-maxage with stale-while-revalidate at the CDN Must pick up new deploys quickly; revalidation costs a cheap 304
Public API data (catalogue, prices shown to everyone) public, s-maxage=60, stale-while-revalidate=300, plus purge on change for things like price corrections Offloads the origin; freshness bounded and explicit
Anything personalised (/api/me, cart, orders, account pages) private, no-store Must never be stored by a shared cache
Mutations and auth endpoints no-store Never cacheable

What goes badly wrong with "cache everything": the CDN caches a personalised response keyed only by URL, and the next user who requests /api/me or /account sees someone else's name, address and orders. This is a well-known class of real incident. Defaults that usually protect you (not caching responses with Set-Cookie or requests with Authorization) are exactly what "cache everything" rules override. Related traps:

  • Vary must match what the response depends on: Accept-Encoding for compression, Accept-Language if localised. Vary: Cookie effectively disables shared caching, which is often the honest answer for personalised HTML.
  • Cache keys: tracking parameters (utm_*) fragment the cache; normalise them at the CDN. Conversely, ignoring a query parameter that changes the response serves wrong content.
  • Web cache deception: a path like /account/profile.css that the app serves as the profile page but the CDN treats as a static file, so it caches it. Only cache by explicit headers from the origin, not by file extension.
  • Stale after a fix: an error or bad price cached for an hour. Keep TTLs short where correctness matters, cache error responses only briefly, and have a purge path.

Make it enforceable: set headers centrally (FastAPI middleware or a route decorator that requires choosing a policy), default to no-store for API routes so caching is opt-in, and add tests that assert the Cache-Control header for each route class.

Follow-ups:

  1. "What's the difference between no-cache and no-store?" no-cache allows storing but requires revalidation with the origin before each reuse (cheap with ETag and 304). no-store forbids storing the response at all. Personal data wants no-store (and private as belt and braces); HTML that must be fresh but is shared wants no-cache.
  2. "Prices change during a flash sale at 12:00 IST. How do you avoid serving stale prices from the CDN?" Either short s-maxage on price endpoints with a purge triggered by the price change event, or keep the cached catalogue data price-free and fetch prices (and the final price at checkout, always from the origin) separately. The checkout must never trust a cached price.

Scoring: Strong: Defines policies by response class (immutable hashed assets, revalidated HTML, short-TTL public data, private no-store personalised data); explains the personalised-response leak and cache deception; handles Vary and cache keys; makes the policy opt-in and tested. Acceptable: Knows hashed static assets can be cached long and personalised pages must not be cached publicly, but no systematic policy or enforcement. Red flag: Enables "cache everything" with a long TTL, or relies on the CDN to detect what is personalised.

SR-TH-FS-06 · Deploying frontend and backend together doesn't keep them in sync

Hard & Tricky · Theory · Senior (5–8 years) · deploy skew, SPA, code splitting, backward compatibility, expand/contract, release engineering · 7 min

Question: The team deploys the React frontend and the FastAPI backend in the same pipeline, at the same moment, "so they're always in sync". After each release, error tracking shows a spike of ChunkLoadError and TypeError: cannot read properties of undefined (reading 'amount') for an hour or so. What's happening?

Answer:

Trap: "Same pipeline, same moment, so every user runs the matching frontend and backend."

Deploying together synchronises the servers, not the clients. A browser tab opened before the release keeps running the old JavaScript for hours or days. That creates two kinds of skew:

  1. Old frontend, missing assets. SPAs split code into lazily loaded, content-hashed chunks. If the deploy replaces the asset bucket, an old tab that navigates to a new route requests checkout.8d2e1f.js, which no longer exists, and gets a 404 (often the SPA's index.html served as a fallback, which then fails to parse as JavaScript): ChunkLoadError.
  2. Old frontend, new API. The backend renamed or restructured a field (amount moved under price.amount), so old clients read undefined. The reverse also happens during a rolling backend deploy: a new frontend can hit an old backend pod for a few minutes.

Mobile apps make this permanent: some users run a months-old version.

Fixes, as release rules:

  • Never delete old assets at deploy time: upload the new hashed files first, then switch index.html (served with no-cache); keep previous versions' chunks for a few days or releases, then expire them.
  • API changes are backward compatible across at least one client generation: expand/contract. Add price.amount alongside amount, release the frontend that uses the new field, wait until old clients have aged out (measure it), then remove amount.
  • Make the version visible: the frontend sends its build ID (e.g. an X-Client-Version header); the backend logs it, so you can see how many old clients are still active before contracting, and can return a "please refresh" signal for versions you've stopped supporting. Mobile apps get a minimum-supported-version check.
  • Handle the failure gracefully on the client: catch chunk-load failures and reload once (guarding against reload loops), and show a "new version available" prompt when the build ID changes.
  • Feature flags decouple deploying code from enabling behaviour, so a new UI path can wait until the backend it needs is fully rolled out.

Follow-ups:

  1. "How long should you keep old frontend assets and old API fields?" Decide from data: the distribution of client build IDs seen in requests after a release. For a web app, most tabs refresh within a day or two, so keeping assets for a week or the last few releases is typical. For mobile, API fields may need to live for months, tied to the minimum supported app version.
  2. "Would server-side rendering eliminate the skew?" It reduces it, since each navigation fetches fresh HTML, but client-side navigation in hydrated apps still runs the loaded JavaScript, and API calls from that JavaScript still hit a backend that may have changed. The same rules apply: keep old assets available and change APIs compatibly.

Scoring: Strong: Explains that clients keep running old code; identifies deleted hashed chunks and API incompatibility (both directions during rolling deploys); prescribes keeping old assets, expand/contract APIs, client version telemetry, graceful reload and feature flags. Acceptable: Identifies old tabs running old JavaScript and suggests keeping old assets or forcing a reload, but not API compatibility rules. Red flag: Proposes forcing every user to reload at each deploy as the only fix, or disabling code splitting.

SR-TH-GEN-01 · Returning a generator vs a list from a library function

Easy · Theory · Senior (5–8 years) · generators, API design, laziness, iterators, type hints, resource lifetime · 4 min

Question: You're designing load_transactions(account_id) for a shared data-access library. Should it return a list or a generator? What does each choice commit you and your callers to?

Answer:

It's an API decision with real consequences, not a style choice.

A generator (lazy iterator)

  • Memory is O(1) per item, the caller can stop early, and the first item is available sooner. Good for large or unbounded data.
  • Single pass: a second loop silently sees nothing. Callers can't call len(), index, or check emptiness with if result: (a generator is always truthy).
  • Errors are deferred: a failed query or bad credentials surface at the first next(), often in a different function.
  • Resource lifetime is tied to iteration: a DB cursor or file stays open until the generator is exhausted or closed. If the caller abandons it, cleanup depends on garbage collection.

A list

  • Reusable, sized and indexable. Errors happen inside the call. Resources are released before it returns.
  • Memory is O(n), and the caller waits for everything.

Guidelines for a shared library:

  • Return a list (or tuple) when the result is bounded and modest, which covers most "get" functions.
  • Use a generator for streams, and make it obvious: name it iter_transactions, annotate it -> Iterator[Txn] (not Iterable, which promises reusability), and document that it's single-pass.
  • For resource-holding streams, return a context manager (with open_transaction_stream(...) as txns:) so cleanup is deterministic.
  • Validate arguments eagerly, before the lazy part starts (see SR-TH-GEN-02).

Follow-ups:

  1. "Why annotate a generator return as Iterator[T] rather than Iterable[T]?" Iterable only promises __iter__, and lists are iterables that can be looped over repeatedly. Iterator tells the caller it's a one-shot stream, so type-aware readers and reviewers expect single-pass behaviour.
  2. "A caller needs both the count and the items from your generator. What do you tell them?" Materialise with list() if it fits in memory. Otherwise count while processing in a single pass, or ask the library for a separate count query. Don't run the generator function twice: that's two queries and the data may change between them.

Scoring: Strong: Frames it as a contract (single pass, truthiness, deferred errors, resource lifetime vs memory and latency), gives clear guidelines including naming, Iterator typing and context managers for resources. Acceptable: Explains memory vs reusability and picks sensibly. Red flag: Always returns generators "because they're more efficient", or always lists without considering size.

SR-TH-GEN-02 · Argument validation in a generator function runs late

Easy & Tricky · Theory · Senior (5–8 years) · generators, validation, error handling, API design, debugging · 4 min

Question: read_batches(rows, size) is a generator function that starts with if size <= 0: raise ValueError(...). A job calls batches = read_batches(rows, size=cfg.batch_size) during setup and hands batches to a worker that consumes it ten minutes later. cfg.batch_size is 0. When and where is the error raised?

Answer:

Trap: "Immediately, at the read_batches(...) call, because the check is the first line of the function."

Calling a generator function runs none of its body. It creates a generator object with a suspended frame. The body, including the validation, starts only at the first next(). So setup "succeeds", and the ValueError is raised ten minutes later inside the worker. Its traceback points at the consumer, far from the config that caused it, possibly after partial work or in a different thread or process.

Fix: split the function into an eager wrapper that validates and a lazy inner generator that does the work:

def read_batches(rows, size):
    if size <= 0:
        raise ValueError("size must be positive")
    return _read_batches(rows, size)   # plain function: runs immediately

The same applies to anything you want to fail fast: opening files, checking credentials, resolving a table name. Do it in the eager part, or the caller gets a generator that's guaranteed to fail later. The trade-off is that eager resource acquisition reintroduces the resource-lifetime question; use a context manager when the eager part opens something.

def read_batches(rows, size):
    if size <= 0:
        raise ValueError("size must be positive")
    for i in range(0, len(rows), size):
        yield rows[i:i + size]


batches = read_batches(["r1", "r2", "r3"], size=0)
print("created", type(batches).__name__)
try:
    next(batches)
except ValueError as e:
    print("raised on first next():", e)


def _read_batches(rows, size):
    for i in range(0, len(rows), size):
        yield rows[i:i + size]


def read_batches_checked(rows, size):
    if size <= 0:
        raise ValueError("size must be positive")
    return _read_batches(rows, size)


try:
    read_batches_checked(["r1", "r2", "r3"], size=0)
except ValueError as e:
    print("raised at call:", e)

Output:

created generator
raised on first next(): size must be positive
raised at call: size must be positive

Follow-ups:

  1. "Does the same apply to async def functions?" Yes. Calling a coroutine function only creates a coroutine object, and nothing runs until it's awaited. Async generators are the same until the first __anext__(). Validate in a synchronous wrapper, or await promptly.
  2. "How would a unit test catch this design issue?" Assert that invalid arguments raise at call time: with pytest.raises(ValueError): read_batches(rows, 0) without iterating. The test fails for the naive generator, which documents the intended contract.

Scoring: Strong: Explains that the body doesn't run until the first next(), describes the operational cost (late, misattributed failures), and gives the eager-wrapper pattern with a test. Acceptable: Knows generators are lazy and moves validation outside after a prompt. Red flag: Expects the error at the call, or suggests calling next() once during setup to "prime" the generator.

SR-TH-GEN-03 · How a generator pipeline actually executes, and what it gives you for free

Medium · Theory · Senior (5–8 years) · generators, pipelines, streaming, backpressure, itertools.batched, ETL · 6 min

Question: You've built an ETL step as chained generators: read → parse → filter → batched(…, 2) → write. Describe the order in which work happens, what you get for free (memory, backpressure, early exit), and what this design does not give you.

Answer:

A generator pipeline is pull-based. Nothing runs until the consumer (the write loop) asks batched for a batch. batched pulls from filter, which pulls from parse, which pulls from read, one item at a time, until it has two qualifying orders. The output shows the interleaving: three reads (A1 is dropped by the filter), one write, then the remaining reads and the final partial batch.

What you get for free:

  • Bounded memory: only about one item per stage plus the current batch is in memory, whatever the file size.
  • Natural backpressure: a slow writer slows the reader automatically, because the reader only runs when asked.
  • Early exit: stopping the consumer (break, islice) stops all upstream work.
  • Composability: each stage is a small, testable function over an iterator.

What it doesn't give you:

  • Concurrency. It's a single thread. While write waits on the network, nothing reads or parses. For overlap, use bounded queue.Queue(maxsize=…) between threads, an asyncio pipeline with async generators and bounded queues, or processes for CPU-heavy stages.
  • Simple error handling: an exception in any stage unwinds the whole chain, and the generator that raised is finished (see SR-TH-GEN-06). Per-record error handling has to be designed into the stages.
  • Resumability: generator state can't be checkpointed (see SR-TH-GEN-05).
  • Visibility: add counters per stage (rows read, parsed, dropped, written) or you can't tell where records went.

from itertools import batched


def read_lines(lines):
    for line in lines:
        print("read", line)
        yield line


def parse(lines):
    for line in lines:
        order_id, amount = line.split(",")
        yield order_id, int(amount)


def only_large(orders, min_amount):
    for order in orders:
        if order[1] >= min_amount:
            yield order


raw = ["A1,500", "A2,2500", "A3,9000", "A4,40", "A5,7000"]
for batch in batched(only_large(parse(read_lines(raw)), 1000), 2):
    print("write", batch)

Output:

read A1,500
read A2,2500
read A3,9000
write (('A2', 2500), ('A3', 9000))
read A4,40
read A5,7000
write (('A5', 7000),)

Follow-ups:

  1. "The writer does a 50 ms bulk insert per batch and reading is also slow. How would you overlap them while keeping memory bounded?" Run the reader and parser in a producer thread that puts batches on a queue.Queue(maxsize=4), and have the main thread write. The bounded queue gives backpressure (the producer blocks when the writer falls behind), and both I/O waits release the GIL. Make sure the producer's exceptions reach the main thread, e.g. by putting an error marker on the queue.
  2. "Where would you put the transaction boundary and checkpoint in this pipeline?" At the batch write: commit the batch and record the progress marker (last source offset) in the same transaction, or make the write idempotent by key. A crash then replays at most one batch without duplicates.

Scoring: Strong: Explains pull-based interleaving (matching the output), bounded memory, backpressure and early exit, and clearly states the limits (no concurrency, error unwinding, no checkpointing, observability) with remedies. Acceptable: Knows generators stream one item at a time and save memory; partially explains the order. Red flag: Thinks each stage processes the whole input before the next begins, or that generators run stages in parallel.

SR-TH-GEN-04 · `if not refunds:` is never true for a generator

Medium & Tricky · Theory · Senior (5–8 years) · generators, truthiness, emptiness checks, peek, itertools.chain, refactoring · 4 min

Question: A nightly refund job did refunds = [r for r in rows if r["status"] == "PENDING"]; if not refunds: return before sending a file to the bank. A refactor "for memory" changed the list into a generator function. Now the bank receives empty refund files on quiet nights and has raised a complaint. Why, and how do you check emptiness of a stream correctly?

Answer:

Trap: "not refunds works the same for any collection, so it's still false when there are no refunds."

A generator has neither __bool__ nor __len__, so it uses the default truthiness for objects: always True. The emptiness check never fires, and the job always creates and sends the file. That's what the bank sees. The same happens with map, filter, zip objects, file objects and most iterators. Meanwhile range(0), empty dict views and empty strings are falsy. Truthiness depends on the type, so a refactor that changes the type can silently change control flow.

To check whether a stream is empty without materialising it, peek at the first item and put it back:

_EMPTY = object()
first = next(it, _EMPTY)
if first is _EMPTY:
    return
stream = itertools.chain([first], it)

Use a private sentinel rather than None in case None is a valid item. If the data is small (a nightly refund list usually is), the simplest fix is to keep the list: the memory "optimisation" didn't need to happen. Also add a test for the empty case, which is what would have caught this refactor.

from itertools import chain

_EMPTY = object()


def pending_refunds(rows):
    for r in rows:
        if r["status"] == "PENDING":
            yield r


refunds = pending_refunds([{"id": 1, "status": "DONE"}])
if not refunds:
    print("nothing to send")
else:
    print("sending file to bank; truthy:", bool(refunds))


def peek(iterable):
    it = iter(iterable)
    first = next(it, _EMPTY)
    return None if first is _EMPTY else chain([first], it)


print(peek(pending_refunds([{"id": 1, "status": "DONE"}])))
print(list(peek(pending_refunds([{"id": 2, "status": "PENDING"}]))))

Output:

sending file to bank; truthy: True
None
[{'id': 2, 'status': 'PENDING'}]

Follow-ups:

  1. "How could type checking or linting have caught this refactor?" If the function is annotated -> Iterator[Refund], mypy's opt-in truthy-bool error code flags if not refunds: the type defines neither __bool__ nor __len__, so the condition is always the same. (truthy-iterable does the same for Iterable annotations.) Enabling these in CI catches this class of bug.
  2. "The bank file has a header and trailer with the record count. Does that change the design?" Yes. If the count must be written before the records, you either materialise the list, or write records to a temporary file while counting and then assemble header, body and trailer, renaming atomically at the end. A pure single-pass stream can't know the count up front.

Scoring: Strong: Explains generator truthiness (no __bool__/__len__, default True), contrasts with falsy built-ins, gives the peek-with-sentinel pattern, and questions whether laziness was needed; adds an empty-case test. Acceptable: Knows generators are always truthy and fixes by converting to a list. Red flag: Uses len(refunds) (TypeError) or if refunds is None, or blames the bank's file parser.

SR-TH-GEN-05 · Resumable pagination needs explicit state that a generator can't give you

Hard · Theory · Senior (5–8 years) · generators, iterators, checkpointing, backfills, pagination, pickle, resumability · 8 min

Question: A backfill pulls 40 million transactions from a partner's cursor-paginated API with a neat generator, def txns(): ...yield from page.... It's killed by a deploy six hours in and restarts from the beginning. The team wants to "just save the generator to disk and resume". Why can't they, and how would you design a resumable reader?

Answer:

A generator's state is its suspended frame: local variables, the instruction pointer and the evaluation stack. It's opaque. You can't pickle, deepcopy, inspect reliably, or rebuild it in another process or after a restart, and pickle refuses outright (cannot pickle 'generator' object). So "save the generator" isn't an option. Resumability has to be designed in as explicit, serialisable state.

Two designs:

  1. Iterator class with a state() method. The position is plain data: the page cursor plus an offset within the page. You can checkpoint it as JSON, and TxnIterator(**saved) resumes. That's the example below.
  2. Generator that yields positions with items: def txns(cursor=None): ... yield item, (cursor, offset). The consumer saves the position after it has committed the item. This is often the neatest option: the generator stays simple, and the checkpoint logic sits with the code that knows when work is durable.

Rules that make it correct, not just resumable:

  • Checkpoint after the write is committed, ideally in the same transaction, or accept at-least-once delivery and make the sink idempotent (upsert by transaction ID). Never checkpoint before processing, or a crash loses records.
  • Checkpoint per batch or every N seconds, not per item. Writing checkpoints too often becomes the bottleneck.
  • Know the source's cursor semantics: is it stable under concurrent inserts, and does it expire? An expired cursor may force resuming by timestamp with an overlap window plus deduplication.
  • Expose progress metrics (position, rate, ETA), since a six-hour job needs observability.

import pickle

PAGES = {None: (["t1", "t2"], "c2"), "c2": (["t3", "t4"], "c3"), "c3": (["t5"], None)}


def fetch_page(cursor):
    return PAGES[cursor]


def txn_gen(cursor=None):
    while True:
        items, cursor = fetch_page(cursor)
        yield from items
        if cursor is None:
            return


class TxnIterator:
    """Position is plain data (page cursor + offset), so it can be checkpointed."""

    def __init__(self, cursor=None, offset=0):
        self.cursor, self.offset = cursor, offset
        self._page = self._next = None

    def state(self):
        return {"cursor": self.cursor, "offset": self.offset}

    def __iter__(self):
        return self

    def __next__(self):
        while True:
            if self._page is None:
                self._page, self._next = fetch_page(self.cursor)
            if self.offset < len(self._page):
                item = self._page[self.offset]
                self.offset += 1
                return item
            if self._next is None:
                raise StopIteration
            self.cursor, self.offset, self._page = self._next, 0, None


gen = txn_gen()
next(gen)
try:
    pickle.dumps(gen)
except TypeError as e:
    print("TypeError:", e)

it = TxnIterator()
print(next(it), next(it), next(it))
saved = it.state()                  # persist after the batch is committed
print(saved)
print(list(TxnIterator(**saved)))   # after a crash or redeploy

Output:

TypeError: cannot pickle 'generator' object
t1 t2 t3
{'cursor': 'c2', 'offset': 1}
['t4', 't5']

Follow-ups:

  1. "The partner's cursor expires after 24 hours and the backfill was paused for two days. What now?" Fall back to a stable, queryable position such as created_at or a monotonically increasing ID. Resume from the last committed timestamp minus a safety overlap, and rely on the idempotent sink to discard duplicates. Record both the cursor and the timestamp in the checkpoint for this reason.
  2. "Why not make the iterator class itself picklable and pickle it to the checkpoint store?" Pickle ties checkpoints to the class's internal layout and Python version, can't be read by other tools, and is unsafe to load if the store is shared. A small explicit JSON state with a version field is stable, inspectable and migratable.

Scoring: Strong: Explains why generator state can't be persisted, designs explicit serialisable position (class or yield-with-position), checkpoints after commit with an idempotent sink, and covers cursor semantics and observability. Acceptable: Knows generators can't be pickled and proposes saving the last cursor, but misses commit ordering or idempotency. Red flag: Suggests pickling or dill-ing the generator, or checkpointing before processing.

SR-TH-GEN-06 · Once a generator raises, it's finished, so "skip and continue" loses the rest

Hard & Tricky · Theory · Senior (5–8 years) · generators, exceptions, data loss, error handling, dead-letter, ETL · 5 min

Question: A settlement job sums amounts from a parsing generator. To tolerate bad rows, the consumer wraps next(gen) in try/except ValueError: skipped += 1; continue. The input has one bad row in the middle. What total does the job report, and why is this worse than a crash?

Answer:

Trap: "The consumer skips the bad row and carries on, so the total is everything except abc (825)."

When an exception propagates out of a generator's frame, the generator is finished: its frame is discarded and every later next() raises StopIteration. The consumer catches the ValueError, counts one skip, calls next() again, gets StopIteration and ends. The rows after the bad one (400, 75) are never processed, and the job reports a total of 350 with "1 skipped". That's silent data loss with a misleading metric that suggests only one row was lost. A crash would at least have been noticed.

The same applies to errors injected with gen.throw(): unless the generator catches them internally, it ends.

Fix: handle per-record errors inside the generator, where execution can continue past the bad row:

  • Catch inside the loop body and send the bad row to a dead-letter list, queue or table with its line number and error, then continue.
  • Or yield result objects (Ok(value) / Err(row, error)) and let the consumer decide.

Then reconcile the counts (rows in = processed + dead-lettered) and alert when the dead-letter rate passes a threshold. For financial data, also decide whether any bad row should block settlement rather than being skipped.

def parse_amounts(lines):
    for line in lines:
        yield int(line)                 # raises on a bad row


rows = ["100", "250", "abc", "400", "75"]
gen = parse_amounts(rows)
total, skipped = 0, 0
while True:
    try:
        total += next(gen)
    except ValueError:
        skipped += 1                    # "skip the bad row and carry on"
        continue
    except StopIteration:
        break
print(total, skipped)


def parse_amounts_safe(lines, errors):
    for n, line in enumerate(lines, 1):
        try:
            yield int(line)
        except ValueError:
            errors.append((n, line))    # dead-letter with its position


errors = []
print(sum(parse_amounts_safe(rows, errors)), errors)

Output:

350 1
825 [(3, 'abc')]

Follow-ups:

  1. "How could you have detected this in the original job without reading the code?" Count reconciliation: rows read from the source (5) versus rows summed (2) plus rows skipped (1) doesn't add up. Any pipeline that drops data should emit counts that must balance, and alert when they don't.
  2. "Does the same happen with a class-based iterator that raises from __next__?" Not necessarily. A class iterator keeps its state in attributes, so if it advanced its position before raising, the next __next__ call continues with the following row. Whether that's safe depends on the implementation. A generator, by contrast, is always finished once an exception escapes it.

Scoring: Strong: Predicts 350 1, explains that an escaping exception finishes the generator, calls out the misleading metric, moves error handling into the generator with dead-lettering, and adds count reconciliation. Acceptable: Gets the output after reasoning and fixes it by catching inside the generator. Red flag: Predicts 825 1, or "fixes" it by recreating the generator after each error (which reprocesses rows from the start).

SR-TH-HSH-01 · Hidden linear scans to look for when reviewing a slow job

Easy · Theory · Senior (5–8 years) · dicts, sets, membership testing, time complexity, pandas, code review · 4 min

Question: You're reviewing a fraud-screening job that takes four hours. Which hashing-related patterns do you look for first, and why do they matter at this scale?

Answer:

Membership and lookup patterns that look O(1) but aren't, inside loops over lakhs of rows:

  • x in some_list (or list.index, list.remove) inside a loop: O(n) each, O(n·m) overall. Build a set/dict once, outside the loop.
  • value in d.values(): a linear scan every time. If you need reverse lookups, maintain an inverted dict. key in d and key in d.keys() are fine.
  • Rebuilding the set per iteration: if vpa in set(blocked) inside the loop, or a helper that calls set(...) on every call. It's even slower than the list.
  • x in series on a pandas Series checks the index, not the values. It's fast and wrong: "mule@upi" in blocked_vpas is False even though the value is there. Use set(series), series.isin(...) for vectorised checks, or x in series.values (a linear scan).
  • Expensive keys: building f-string or tuple keys of large objects per lookup, or keys whose __hash__/__eq__ are Python methods. Hashing cost is paid on every lookup.
  • Row-by-row lookups at all: for a join of 50 lakh transactions against a blocklist, df.merge or isin does the hashing in C. Python loops with dict lookups are the fallback, not the default.

Then measure: cProfile sorted by tottime shows the scan as a hot line, and fixing just that is often the 4-hours-to-4-minutes change.

import pandas as pd

blocked_vpas = pd.Series(["fraud@upi", "mule@upi", "test@upi"])
print("mule@upi" in blocked_vpas)          # checks the INDEX (0, 1, 2), not the values
print(1 in blocked_vpas)
print("mule@upi" in set(blocked_vpas))     # build once, then O(1) lookups

ifsc_to_bank = {"SBIN0000001": "SBI", "HDFC0000001": "HDFC"}
print("HDFC" in ifsc_to_bank.values())     # correct, but a linear scan every time

Output:

False
True
True
True

Follow-ups:

  1. "The blocklist has 2 crore entries. Is a Python set still the right tool?" At roughly 80 bytes per short string entry, it's about 1.6 GB per process, multiplied by every worker, plus a slow load at startup. Consider a sorted NumPy array with searchsorted, an on-disk index (SQLite/LMDB, memory-mapped and shared across workers), Redis, or a Bloom filter in front if false positives can be re-checked.
  2. "How do you stop this class of bug from coming back?" A benchmark on realistic volumes in CI (even a coarse one, failing on 3× regression), code-review checklist items for in on lists and .values(), and pushing joins into vectorised or SQL operations by default.

Scoring: Strong: Lists the O(n) membership traps including .values() and set rebuilding, knows in on a Series checks the index, prefers vectorised joins, and insists on profiling to confirm. Acceptable: Spots in list and proposes sets, but misses the pandas index trap or .values(). Red flag: Suggests parallelising the loop with threads before fixing the algorithm.

SR-TH-HSH-02 · Equal dicts, different JSON, different idempotency keys

Easy & Tricky · Theory · Senior (5–8 years) · dicts, JSON, canonical serialization, idempotency keys, cache keys, hashing · 5 min

Question: A payments service derives an idempotency key as sha256(json.dumps(payload)). The API path builds the payload in one order and the retry worker rebuilds it from the DB in another. Duplicate charges appear. The two dicts compare equal in a debugger. What's happening, and how should such keys be built?

Answer:

Trap: "The dicts are equal (== is True), so they serialise to the same JSON." Dict equality ignores order, but dicts preserve insertion order (3.7+) and json.dumps writes keys in that order. Different construction order means different bytes, a different hash and a different idempotency key, so the retry isn't recognised as a duplicate.

Build keys from a canonical serialization:

  • json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False), encoded as UTF-8. sort_keys applies recursively to nested dicts.
  • Normalise values before serialising: 1499 vs 1499.0 vs Decimal("1499.00") serialise differently; use integer paise or a fixed decimal string. Timestamps: one format, UTC. Unicode: normalise names to NFC (unicodedata.normalize), since "José" can arrive composed or decomposed.
  • Include only the fields that define the operation (merchant, order ID, amount, currency), not incidental ones like request_time or trace_id, and version the scheme ("v1:" + digest) so you can change it later.
  • Better still, let the client send the idempotency key (as Stripe-style APIs do) and store it with a unique constraint; derived keys are a fallback.

The same mechanism breaks cache keys (low hit rate), ETags, and signature verification. For webhooks, verify the HMAC over the raw request bytes, never a re-serialised dict.

import hashlib
import json

from_api = {"order_id": "ORD-9", "amount": 1499, "currency": "INR"}
from_db = {"currency": "INR", "order_id": "ORD-9", "amount": 1499}

print(from_api == from_db)

def key(payload, **dumps_opts):
    return hashlib.sha256(json.dumps(payload, **dumps_opts).encode()).hexdigest()[:12]

print(key(from_api), key(from_db))
canon = {"sort_keys": True, "separators": (",", ":"), "ensure_ascii": False}
print(key(from_api, **canon), key(from_db, **canon))

Output:

True
6826a972b352 b18c04162086
20202307d344 20202307d344

Follow-ups:

  1. "Is sort_keys=True enough to make JSON canonical across languages?" Not fully: number formatting (1e3 vs 1000, float shortest-repr differences), escaping (ensure_ascii), and key sorting by UTF-16 code units vs code points differ between implementations. If another language must reproduce the key, use a spec like RFC 8785 (JSON Canonicalization Scheme), or hash an explicit, ordered tuple of normalised fields instead of JSON.
  2. "Why not use Python's built-in hash(frozenset(payload.items()))?" hash() of strings is randomised per process (PYTHONHASHSEED), so the key differs between the API process and the worker. It's also only 64 bits and fails on unhashable values like nested dicts. Use a stable cryptographic digest over a canonical byte string.

Scoring: Strong: Explains equality vs insertion-ordered serialisation, gives a canonical form with sorted keys, fixed separators and normalised values, scopes the key to identity fields with a version, and prefers client-supplied idempotency keys. Mentions raw-bytes HMAC. Acceptable: Fixes it with sort_keys=True but doesn't normalise values or scope the fields. Red flag: Thinks dict order is random, or proposes Python's hash() for a cross-process key.

SR-TH-HSH-03 · Choosing a lookup structure for 5 crore reference records

Medium · Theory · Senior (5–8 years) · dicts, memory, NumPy searchsorted, SQLite, mmap, Bloom filters, capacity planning · 7 min

Question: Your API checks every outgoing SMS against the national Do-Not-Disturb registry: about 5 crore mobile numbers, refreshed daily. The service runs 8 workers per pod. A teammate loads the registry into a Python set at startup. Evaluate that and the alternatives.

Answer:

Start with numbers. A Python set or dict of 10-digit numbers costs roughly 80 bytes per entry measured with tracemalloc (the int or str object plus hash-table slots). 5 crore × 80 B ≈ 4 GB per worker, so about 32 GB per pod, plus a startup that parses 5 crore lines (minutes) and a 2× peak while swapping in the daily refresh. That fails on memory before it fails on speed.

Options, with trade-offs:

  • Sorted NumPy int64 array + np.searchsorted: 8 bytes per entry, about 400 MB, O(log n) lookups (~26 comparisons) in microseconds. Save it with np.save and load with mmap_mode="r": all 8 workers share the same page-cache pages, so the pod pays ~400 MB once. Refresh by writing a new file and atomically swapping the path.
  • Embedded key-value store (SQLite with a primary-key table, LMDB): also memory-mapped and shared across processes, supports incremental updates, and slightly slower per lookup. A good fit if you also need the opt-out category or date.
  • Redis/managed KV: one copy shared by all pods, easy refresh, but a network hop per check (batch with pipelining) and a new dependency on the hot path; define what happens when it's down (for DND, fail closed: don't send).
  • Bloom filter in front: around 60 MB at a 1% false-positive rate for 5 crore entries. A "maybe present" result is confirmed against the exact store; "absent" is definite. Useful when most numbers are not in the registry.

A Python set is right when the data is small (tens of lakhs at most), per-process memory is affordable, and refresh is rare. Decide on memory per pod × replicas, startup time, refresh semantics and failure mode, not on lookup speed alone.

Follow-ups:

  1. "How do you refresh the mmap'd array daily without serving a half-written file?" Write dnd-2026-10-04.npy to a temp name, fsync, then os.replace a stable symlink or pointer file. Workers check the pointer periodically and open the new map; old maps are released when no request uses them. Never rewrite a file that readers have mapped.
  2. "Why does mmap sharing work across gunicorn workers when a Python set doesn't?" The mapped file's pages live in the kernel page cache and are mapped read-only into each process, so they're one physical copy. A Python set is made of heap objects with reference counts that each process writes to, so even after fork the pages get copied, and each worker that builds its own set pays the full cost.

Scoring: Strong: Estimates memory per entry and per pod, rejects the per-worker set on capacity grounds, proposes a shared memory-mapped sorted array or embedded KV with atomic refresh, and discusses failure modes (fail closed) and Bloom filters. Acceptable: Recognises the memory problem and suggests Redis or a database, but without numbers or the per-worker multiplication. Red flag: Says lookups are O(1) so the set is fine, without considering memory, startup or refresh.

SR-TH-HSH-04 · A dict used as a FIFO gets slower and never gives memory back

Medium & Tricky · Theory · Senior (5–8 years) · dict internals, deleted entries, OrderedDict, deque, memory, performance · 6 min

Question: A rate-limiting middleware keeps recent request IDs in a plain dict "because dicts are ordered": it evicts the oldest with del d[next(iter(d))] and adds new IDs at the end. Under load, CPU climbs and memory stays at its peak even after traffic drops. Explain both symptoms.

Answer:

Trap: "Dicts keep insertion order, so a dict is an O(1) FIFO." Insertion order comes from how CPython stores entries: a dense entries array in insertion order plus a sparse hash index. Deleting a key doesn't move anything; it leaves a dummy slot in the entries array. Two consequences:

  • next(iter(d)) gets slower. Iteration starts at the beginning of the entries array and skips dummies one by one. In a FIFO, every eviction adds another dummy at the front, so finding the oldest key costs O(number of evictions since the last resize). The total becomes quadratic between resizes. In a quick benchmark, 4× more entries made the same eviction loop about 15× slower, while OrderedDict scaled linearly.
  • Memory isn't returned on delete. A dict never shrinks when you delete keys; the table is resized (and compacted) only when an insert runs out of space, sized for the live keys at that moment. After a spike, a dict that held 1 lakh session IDs keeps a table sized for 1 lakh even with 10 entries left (below: 3.8 MB vs 272 bytes for a fresh copy).

What to use instead:

  • collections.OrderedDict with popitem(last=False) and move_to_end(): a doubly linked list gives true O(1) pops from either end. It's the right base for FIFO/LRU structures (and cachetools builds on similar ideas).
  • deque(maxlen=N) plus a set or dict when you need a bounded window with membership checks.
  • To reclaim memory from a dict that shrank permanently, rebuild it: d = dict(d) (or swap in a new one) at a quiet moment.

And for a rate limiter across multiple workers, in-process state is the wrong place anyway (Redis with TTLs).

import sys

sessions = {f"sess-{i}": i for i in range(100_000)}
full = sys.getsizeof(sessions)

for k in [f"sess-{i}" for i in range(99_990)]:
    del sessions[k]
after_delete = sys.getsizeof(sessions)
compacted = sys.getsizeof(dict(sessions))

print(len(sessions), full, after_delete, compacted)

Output:

10 3844864 3844864 272

Follow-ups:

  1. "Doesn't dict.popitem() give you O(1) removal?" Yes, but it removes the last inserted item (LIFO), which is the opposite of what a FIFO or LRU needs. OrderedDict.popitem(last=False) is the O(1) removal from the front.
  2. "Would functools.lru_cache have been an option for "recent request IDs"?" Not really. It caches function results by argument, its eviction isn't observable, and you can't ask "was this ID seen?" without calling the function, which records it as a hit. Use an explicit structure whose eviction policy and size you control and can measure.

Scoring: Strong: Explains dummy entries in the dense entries array, why front-iteration degrades and why memory isn't returned until an insert-triggered resize, and proposes OrderedDict.popitem(last=False)/deque plus rebuilding to reclaim memory. Acceptable: Knows OrderedDict is the right tool for FIFO/LRU, but can't explain why the plain dict degrades. Red flag: Insists dict is a perfect FIFO, or blames the slowdown on hash collisions without evidence.

SR-TH-HSH-05 · Dict lookups keyed by a frozen dataclass dominate a hot path

Hard · Theory · Senior (5–8 years) · hashing, __hash__, __eq__, dataclasses, performance, profiling · 8 min

Question: In a pricing service, py-spy shows a surprising share of CPU in dict lookups keyed by @dataclass(frozen=True) class RateKey: merchant_id: str; mcc: str; tier: int, built fresh from each request. What does each lookup actually cost, and how would you make it cheaper?

Answer:

A dict lookup is: hash the key, probe the slot, and if the stored hash matches, check stored is key or stored == key. With a frozen dataclass built per request, every part runs Python-level code:

  1. Construction: frozen dataclasses set fields through object.__setattr__ in __init__, noticeably slower than a plain class or tuple.
  2. __hash__: the generated method builds a tuple of the fields and hashes it, on every call. Tuples don't cache their hash (on 3.12). (Strings do: each str caches its hash after the first computation, so the field strings themselves are cheap after the first hash.)
  3. __eq__: the request's key is a different object from the stored key, so the identity shortcut fails and the generated __eq__ builds two field tuples and compares them. One Python call per hit.

The counter below shows it: 10,000 lookups cost 10,000 __hash__ calls and 10,000 __eq__ calls, each a Python frame.

Fixes, cheapest first:

  • Use a plain tuple (merchant_id, mcc, tier) as the key in the hot path: hashing and equality run entirely in C. Keep the dataclass for the API and convert at the boundary.
  • Or a nested dict rates[merchant_id][mcc][tier], which hashes only strings that have cached hashes.
  • If the key object is long-lived and reused, cache its hash at construction (below). It doesn't help for keys created per request, and you must keep __eq__ consistent with it.
  • Also check hash quality: a hand-written __hash__ that returns hash(self.tier) puts all merchants of a tier in the same chain of probes, and lookups degrade towards O(n). Hash all the fields __eq__ compares.

As always, confirm with a micro-benchmark (timeit) and the profiler that the change moves the needle for the endpoint, not just the line.

from dataclasses import dataclass, field

calls = {"hash": 0, "eq": 0}


@dataclass(frozen=True, eq=False)
class RateKey:
    merchant_id: str
    mcc: str
    tier: int

    def _fields(self):
        return (self.merchant_id, self.mcc, self.tier)

    def __eq__(self, other):
        calls["eq"] += 1
        return isinstance(other, RateKey) and self._fields() == other._fields()

    def __hash__(self):                       # what the generated __hash__ does
        calls["hash"] += 1
        return hash(self._fields())


rates = {RateKey(f"M{i}", "5411", 1): i for i in range(1_000)}
calls.update(hash=0, eq=0)
for _ in range(10_000):
    rates[RateKey("M7", "5411", 1)]           # a fresh key object per request
print("per-request key objects:", calls)


@dataclass(frozen=True)
class CachedRateKey:
    merchant_id: str
    mcc: str
    tier: int
    _hash: int = field(init=False, repr=False, compare=False)

    def __post_init__(self):
        object.__setattr__(self, "_hash", hash((self.merchant_id, self.mcc, self.tier)))

    def __hash__(self):
        return self._hash


k = CachedRateKey("M7", "5411", 1)
print(hash(k) == hash(CachedRateKey("M7", "5411", 1)), k)

Output:

per-request key objects: {'hash': 10000, 'eq': 10000}
True CachedRateKey(merchant_id='M7', mcc='5411', tier=1)

Follow-ups:

  1. "Why must _hash be excluded with compare=False?" Otherwise the generated __eq__ would compare _hash too. That's harmless when it's derived from the other fields, but it's redundant work, and it would show up in __repr__ and ordering. More importantly, it documents that _hash is a cache, not data.
  2. "When is a custom __hash__ actually dangerous?" When it hashes fields that can change while the object is a key (mutable dataclass with unsafe_hash=True), or when it disagrees with __eq__ (equal objects with different hashes). Both make keys "disappear" from dicts and sets without any error.

Scoring: Strong: Breaks the lookup into construction, hash and equality costs, knows tuple hashes aren't cached while str hashes are and why the identity shortcut fails, and proposes tuple or nested-dict keys at the hot path, with hash quality and measurement. Acceptable: Suggests tuple keys for speed and knows __hash__ is called per lookup, but can't explain the __eq__ cost or caching behaviour. Red flag: Believes dict lookups are free once O(1), or proposes id(obj) as the key.

SR-TH-HSH-06 · Decimal and float amounts as keys match for ₹0.50 but not for ₹0.10

Hard & Tricky · Theory · Senior (5–8 years) · hashing, Decimal, float, numeric equality, reconciliation, money · 7 min

Question: A settlement reconciliation indexes the bank file by amount (Decimal, from the bank's CSV) and looks up amounts from your JSON API (parsed as float). In UAT everything matched. In production some small amounts never match. Why would ₹0.50 and ₹1,499.25 match but ₹0.10 not?

Answer:

Trap: "Decimal and float are different types, so they'd never match as keys" or "they'd always match because both mean 0.1". Python's numeric types compare by exact mathematical value, and their hashes are defined so that equal numbers of any type hash equal (hash(Decimal("0.5")) == hash(0.5) == hash(Fraction(1, 2))). So a dict lookup with a float finds a Decimal key exactly when the float's binary value equals the decimal value:

  • 0.5 and 1499.25 are exactly representable in binary, so they equal Decimal("0.50") and Decimal("1499.25"): match.
  • The float 0.1 is really 0.1000000000000000055511151231257827021181583404541015625, which is not equal to Decimal("0.10"): no match, no error.

UAT data used whole rupees and 50-paise amounts, so the bug only shows on amounts like ₹0.10, ₹19.99 or ₹2.30.

Other surprises in the same family: Decimal("1.0"), Decimal("1.00"), 1 and 1.0 are all one set element, while str(Decimal("0.10")) != str(Decimal("0.1")), so string keys built from Decimals split what numeric keys merge.

The fix is a single money representation at every boundary:

  • Integer paise as keys (round(Decimal(s) * 100) from the CSV, and the API sends integers too), or
  • Decimal everywhere: parse JSON with json.loads(body, parse_float=Decimal) (or a pydantic Decimal field), then quantize(Decimal("0.01")).

Never let a float touch money, and never key by amount alone in a real reconciliation: amounts collide. Join on reference (UTR) and compare amounts as a check.

from decimal import Decimal

# Settlement amounts from the bank file (Decimal) vs our JSON API (float)
bank = {Decimal("0.50"): "UTR-1", Decimal("0.10"): "UTR-2", Decimal("1499.25"): "UTR-3"}
api_amounts = [0.5, 0.1, 1499.25]

for amt in api_amounts:
    print(amt, bank.get(amt, "NO MATCH"))

print(Decimal("0.10") == 0.1, Decimal(0.1))
print(len({Decimal("1.0"), Decimal("1.00"), 1, 1.0}))

Output:

0.5 UTR-1
0.1 NO MATCH
1499.25 UTR-3
False 0.1000000000000000055511151231257827021181583404541015625
1

Follow-ups:

  1. "Why doesn't Python just raise when you compare a Decimal with a float?" It used to be more restrictive; since 3.2 equality and ordering comparisons between them are exact and allowed, consistent with the numeric tower. Arithmetic mixing (Decimal("1") + 0.1) does raise TypeError. Trapping the FloatOperation signal in the decimal context makes Decimal(0.1) and mixed ordering comparisons raise, a useful setting in a money service's tests, but equality stays silent, so it would not have caught this dict lookup. Only a boundary rule (no floats for money) does.
  2. "Write a test that would have caught this in UAT." Parametrise over amounts that aren't exactly representable in binary (0.10, 0.20, 19.99, 2.30) as well as the easy ones, and assert end-to-end that every API amount reconciles. Better, assert at the boundary that the API layer never produces a float for an amount field.

Scoring: Strong: Explains exact cross-type numeric equality and consistent hashing, predicts which amounts match based on binary representability, notes Decimal scale vs string keys, and fixes it with one money representation (paise or Decimal with parse_float). Acceptable: Recognises float imprecision as the cause and switches to Decimal, but can't explain why some amounts did match. Red flag: Proposes rounding floats to 2 decimals and using them as keys, or matching with abs(a - b) < 0.01.

SR-TH-MLE-01 · Training-serving skew and how a team prevents it

Easy · Theory · Senior (5–8 years) · training-serving skew, feature engineering, feature stores, model serving, data contracts · 6 min

Question: A fraud model scored 0.91 AUC offline but performs much worse in production, though the input traffic looks similar. You suspect training-serving skew. What is it, where does it come from, and what do you put in place so it doesn't happen again?

Answer:

Training-serving skew is any difference between the features (or preprocessing) the model saw in training and what it receives at inference, so the production model is effectively evaluated on a different input than it was trained on.

Typical sources:

  • Two implementations of the same feature: SQL in the training pipeline, Python in the service. Small differences (time zone of "hour of day", how nulls are filled, string normalisation, rounding of ₹ amounts) change the values.
  • Point-in-time mismatch: training features computed from the final state of the warehouse (including events that arrived later, or corrections), while serving sees only what's known at request time. Aggregates like "transactions in the last 24 hours" are the usual victims.
  • Freshness: the online store updates hourly, so serving sees stale aggregates that training never had.
  • Preprocessing drift: a scaler, encoder or vocabulary refitted and deployed separately from the model; unseen categories handled differently.
  • Default values on failure: a feature-store timeout silently returns zeros in production.

What I'd standardise:

  1. One feature definition, used in both paths: a shared library or a feature store where the same transformation produces offline (training) and online (serving) values. Preprocessing ships inside the model artifact (a single pipeline object or exported graph), never separately.
  2. Point-in-time correct training sets: build them by replaying features "as of" each label's timestamp.
  3. Log the features actually served with each prediction, and train future models from those logs where possible.
  4. Skew checks: for a sample of requests, recompute features offline and compare value by value; compare served-feature distributions with training distributions; alert on missing or defaulted features.
  5. Fail loudly: count and alert on fallback values, rather than silently scoring with zeros.

Follow-ups:

  1. "How would you confirm skew is the cause rather than real drift in fraud patterns?" Take a sample of production requests, recompute their features with the training pipeline as of the request time, and compare with the logged served values. If they disagree, it's skew. If they agree but the distribution differs from the training period, it's drift. Then score the same rows offline with both feature versions and compare predictions.
  2. "Is a feature store necessary to avoid skew?" No. The essential parts are one shared definition of each feature, point-in-time correct training data, and logging what was served. A feature store packages those with online/offline storage; for a few models, a shared Python package plus logged features can be enough.

Scoring: Strong: Defines skew precisely; lists dual implementations, point-in-time leakage, freshness, separately deployed preprocessing and silent defaults; prescribes a shared definition, preprocessing inside the artifact, served-feature logging and automated skew checks. Acceptable: Explains skew as "different features in training and serving" and suggests reusing code, without point-in-time or monitoring detail. Red flag: Attributes the gap to overfitting or concept drift without checking the features, or suggests retraining more often as the fix.

SR-TH-MLE-02 · One model in memory, eight worker processes

Easy & Tricky · Theory · Senior (5–8 years) · model serving, gunicorn, multiprocessing, copy-on-write, mmap, memory · 6 min

Question: A recommendation service loads a 3 GB model at module import, so "it's loaded only once". It runs under gunicorn with 8 Uvicorn workers on a 16 GB pod and keeps getting OOM-killed. The author says memory can't be the problem because the model is loaded only once. What's wrong, and what are the options?

Answer:

Trap: "The model is a module-level global, so all workers share one copy."

Gunicorn workers are separate processes. Without --preload, each worker imports the app itself, so the module-level load runs 8 times: 8 × 3 GB = 24 GB on a 16 GB pod. Module globals are per process.

With --preload the master loads the model and then forks, so children start by sharing the pages copy-on-write. That helps less than people expect in CPython: every time a worker touches a Python object, it updates the object's reference count (and the cyclic GC writes to object headers), which dirties the page and forces a private copy. A model made of millions of small Python objects (dicts, lists, tree nodes) gradually un-shares. Large NumPy or tensor buffers fare better, since refcounts live on the array object, not in the data pages, as long as nothing writes to them. gc.freeze() before forking keeps the collector from touching the preloaded objects.

Options, roughly in order:

  • Fewer processes: if inference releases the GIL (NumPy, ONNX Runtime, PyTorch ops), one or two workers with a thread pool can use the cores with one model copy. Measure before assuming you need 8.
  • Memory-map the weights: store them as .npy or safetensors and open with np.load(..., mmap_mode="r") or safetensors' mmap loading. All processes then share the OS page cache read-only, and memory is counted once.
  • Preload plus gc.freeze(), for models whose bulk lives in arrays.
  • A dedicated model server (Triton, TorchServe, ONNX Runtime server, or a single inference process) that the web workers call, so web concurrency and model memory scale separately.
  • Shrink the model: float16 or int8 quantisation, pruning unused embeddings.

And watch the GPU case: each process creates its own CUDA context and copy of the weights, so multi-worker GPU serving almost always means a model server.

Follow-ups:

  1. "How do you measure how much memory is really shared between the workers?" Look at PSS (proportional set size) rather than RSS: RSS counts shared pages in every process, so summing it overstates usage. /proc/<pid>/smaps_rollup gives Pss and Shared/Private_Dirty; tools like smem summarise it. Watching Private_Dirty grow over time shows copy-on-write pages being un-shared.
  2. "Why might the OOM kill only happen hours after startup, with --preload?" Pages are copied lazily as each worker touches objects, so private memory grows over time with traffic until the pod hits its limit. Worker recycling (--max-requests) resets it but re-forks from the master; it's a mitigation, not a fix.

Scoring: Strong: Identifies per-process loading; explains why preload copy-on-write erodes in CPython (refcounts, GC) and why array buffers fare better; proposes threads where the GIL is released, mmap'd weights, a model server, and measures with PSS. Acceptable: Knows each worker loads its own copy and suggests --preload or fewer workers, but doesn't know preload's limits. Red flag: Insists module globals are shared across processes, or just raises the pod memory limit.

SR-TH-MLE-03 · Rolling out a new model version safely

Medium · Theory · Senior (5–8 years) · model deployment, shadow traffic, canary, model registry, rollback, monitoring · 7 min

Question: Your team retrains the delivery-time (ETA) model every month. The new version beats the old one offline by 6% MAE. Walk me through how you'd take it to 100% of traffic, and what has to exist for that to be routine.

Answer:

Offline wins don't guarantee online wins (skew, feedback effects, latency), so I'd roll out in stages with a defined gate at each.

Prerequisites (the platform part):

  • A model registry with immutable versions: artifact, training data snapshot and code commit, feature list and schema, metrics, and owner. Serving loads a version by ID, never "latest".
  • Serving that can run two versions at once and route by a flag or percentage, logging the model version with every prediction.
  • Monitoring per version: latency and error rate, prediction distribution, feature nulls, and the business metric (here, actual delivery time vs predicted, which arrives within the hour).

Stages:

  1. Offline validation beyond the headline metric: error by city, distance band and peak hours; no segment badly worse; latency and memory within budget on production-like hardware.
  2. Shadow: the new model scores live traffic in parallel, its outputs are logged but not used. Checks for crashes, latency, feature skew and differences in predictions from the current model on the same requests, with no user impact.
  3. Canary: a small share of traffic (say 5%, randomised by order or user) uses the new model. Gate on guardrails (error rate, p99 latency, prediction sanity) for a set period.
  4. A/B ramp: 50/50 long enough for the business metric (ETA accuracy, and its effect on cancellations or customer contacts) to be measured with the power you planned; then 100%.
  5. Keep the previous version warm for a fast rollback by configuration, not by redeploying; define the rollback triggers in advance.

Making it routine: the stages are automated in the deployment pipeline with explicit, versioned gate criteria, so a monthly retrain is a change request with evidence attached, not a heroic effort.

Follow-ups:

  1. "When is shadow mode not enough, or even misleading?" When the model's output changes what happens next (feedback loops). A shadow ETA model never affects customer behaviour or rider assignment, so you can't see its downstream effects; a ranking model in shadow can't show how users would click on its ranking. Shadow validates the system; only a randomised test validates the impact.
  2. "The canary shows the same error rate and median latency, but p99 latency is 3× worse for the new model. Ship?" No; p99 is user-visible at scale and often a sign of something structural (larger trees, a slow feature lookup path, a cold cache). Investigate with per-stage timing. If the model is truly slower, decide explicitly whether the accuracy gain is worth the latency, possibly by distilling or optimising it first.

Scoring: Strong: Requires a registry with lineage and version-pinned serving; stages offline, shadow, canary and A/B with explicit gates; logs model version per prediction; keeps a warm rollback; knows shadow can't measure feedback effects. Acceptable: Describes canary or A/B rollout with monitoring and rollback, but no registry, lineage or gate criteria. Red flag: Replaces the model file and restarts the service, relying on the offline metric, or has no rollback plan.

SR-TH-MLE-04 · Monitoring a credit model when labels arrive 90 days late

Medium & Tricky · Theory · Senior (5–8 years) · model monitoring, delayed labels, data drift, PSI, observability · 7 min

Question: The team says the personal-loan approval model is well monitored: a dashboard shows its accuracy every day. A marketing campaign started bringing in very different applicants three weeks ago, and nobody has noticed anything. Why, and what should monitoring look like?

Answer:

Trap: "We track accuracy daily, so any degradation would show up."

For a loan model, the label (did the borrower default?) is only known months after the decision, typically at 90 days past due. Today's "accuracy" is computed on loans from at least three months ago, so the dashboard is a lagging indicator of a population that no longer matches today's. The campaign's applicants won't affect it until their loans mature, by which time the book is already written.

Monitoring has to work in layers, from fastest to slowest:

  1. Data quality (minutes): schema, null rates, out-of-range values, defaulted or fallback features per source. Most production incidents are here.
  2. Input drift (daily): compare each feature's live distribution with the training reference, e.g. the Population Stability Index (a common rule of thumb: below 0.1 stable, 0.1–0.25 worth a look, above 0.25 significant shift), or KS tests for continuous features. In the snippet, the campaign's lower-income applicants give an income PSI of about 0.40, while a fresh sample from the training population gives 0.001.
  3. Prediction drift (daily): the score distribution, approval rate by segment and channel.
  4. Early proxies (weeks): first missed EMI, bounce of the first auto-debit, early repayment behaviour.
  5. Realised performance (months): default rate and calibration by vintage (month of approval) and channel, compared with what the model predicted for that vintage.

Two cautions. Drift isn't degradation: a shift in income may be harmless if the model handles that range well; drift alerts are prompts to investigate (which features, which segment, is it within the training support?), not automatic retrain triggers. And alert on segments, not just the total: a campaign channel can be 5% of traffic and drift badly without moving the overall distribution.

import numpy as np

def psi(reference: np.ndarray, current: np.ndarray, bins: int = 10) -> float:
    """Population Stability Index over reference-quantile bins."""
    edges = np.quantile(reference, np.linspace(0, 1, bins + 1))
    edges[0], edges[-1] = -np.inf, np.inf
    ref = np.histogram(reference, edges)[0] / len(reference)
    cur = np.histogram(current, edges)[0] / len(current)
    ref, cur = np.clip(ref, 1e-6, None), np.clip(cur, 1e-6, None)
    return float(np.sum((cur - ref) * np.log(cur / ref)))

rng = np.random.default_rng(3)
train_income = rng.lognormal(mean=10.5, sigma=0.6, size=100_000)       # ₹ monthly income
same_population = rng.lognormal(mean=10.5, sigma=0.6, size=20_000)
new_channel = rng.lognormal(mean=10.1, sigma=0.6, size=20_000)         # a campaign brings lower incomes

print(f"PSI, same population: {psi(train_income, same_population):.3f}")
print(f"PSI, after campaign:  {psi(train_income, new_channel):.3f}")

Output:

PSI, same population: 0.001
PSI, after campaign:  0.403

Follow-ups:

  1. "Drift alerts fire every week and people have started ignoring them. What do you change?" Alert on features by importance and on segments that matter, not on every feature; tune thresholds against historical false alarms; require sustained drift (several days) rather than one noisy day; and attach context to each alert (which bins moved, which channel). Route low-severity drift to a weekly review instead of paging.
  2. "Rejected applicants never get a label. How does that bias the monitoring and future retraining?" You only observe outcomes for people the model approved, so realised default rates describe the approved population, and retraining on them reinforces the current policy (selection bias). Mitigations include a small randomised exploration band of approvals near the threshold, reject inference techniques, and using bureau data on rejected applicants' later behaviour where permitted.

Scoring: Strong: Recognises label delay makes accuracy a lagging metric; layers data quality, input and prediction drift, early proxies and vintage performance; uses PSI or similar with sensible thresholds; separates drift from degradation and monitors by segment. Acceptable: Recommends input-drift monitoring alongside accuracy, but without the layering or the label-delay reasoning. Red flag: Trusts the daily accuracy dashboard, or retrains automatically whenever any drift metric moves.

SR-TH-MLE-05 · Nightly batch scoring for 50 million customers

Hard · Theory · Senior (5–8 years) · batch inference, multiprocessing, thread oversubscription, idempotency, checkpointing, validation · 9 min

Question: You own a nightly job that scores 50 million customers with a gradient-boosted churn model and publishes the scores to the CRM by 07:00 IST. Today it's a single Python process looping over customers and takes 9 hours. How would you redesign it?

Answer:

First profile one chunk to see where the time goes; per-customer loops usually waste it in three places: per-row Python overhead, per-row I/O (one feature query per customer), and calling predict on one row at a time. Then design for throughput and for re-runs.

Structure:

  1. Partition the work into, say, 500 chunks by a stable hash of customer_id (not Python's hash(), which is salted per process for strings). Each chunk is an independent, retryable unit.
  2. Bulk, columnar I/O: each chunk reads its features from a Parquet snapshot (or one query per chunk), never per customer, with only the columns the model needs.
  3. Vectorised prediction on batches of tens of thousands of rows: one predict call per batch amortises the model's fixed overhead by orders of magnitude.
  4. Parallelism without oversubscription: a ProcessPoolExecutor whose initializer loads the model once per worker (not per task, and not pickled with every task). Gradient-boosting libraries also use OpenMP threads internally, so choose one level: N processes with 1–2 threads each (OMP_NUM_THREADS, the library's n_jobs/nthread), or one process with all threads. Both levels at full width on a 32-core machine gives 1,024 threads fighting over 32 cores.
  5. Idempotent outputs: each chunk writes scores/run_date=.../chunk=0042.parquet to a temp name and renames on completion; a rerun overwrites, never appends. The driver skips chunks that already have a completed file, so a crash at chunk 400 resumes rather than restarts.

Before publishing (the part people skip):

  • Validate the run: every input customer has exactly one score, no NaNs, score distribution close to yesterday's (PSI), feature nulls within bounds. Fail the run rather than publish garbage.
  • Publish atomically: load into a new versioned table or key prefix and switch a pointer (view, alias), so the CRM never sees half of tonight's scores mixed with yesterday's; keep yesterday's for rollback.
  • Record lineage with every row or run: model version, feature snapshot date, run ID.

Operations: alert on "not finished by 05:30" (a deadline, not just failure), emit per-chunk duration metrics, and size the deadline margin from the slowest chunk, not the average. Estimate it with arithmetic before building: if a worker scores 50,000 rows a second including I/O, 50 million rows is about 17 worker-minutes.

Follow-ups:

  1. "Why use processes rather than threads here, given that the model library releases the GIL during prediction?" If prediction dominates and releases the GIL, threads (or the library's own OpenMP threads in one process) can work well and share one model copy. But the surrounding work, such as decoding, feature preparation in pandas and building outputs, is partly GIL-bound Python, and processes isolate memory leaks and crashes per chunk. Measure both; the key is to pick one level of parallelism, not stack them.
  2. "Half the chunks finish in 2 minutes and a few take 40. What's going on?" Skew: chunks of uneven size (a hash on a badly distributed key, or a few chunks holding all the business customers with many features), or straggling workers. Make chunks smaller and more numerous so the pool balances dynamically, check the chunking key's distribution, and look at the slow chunks' inputs.

Scoring: Strong: Profiles first; partitions by a stable hash; bulk columnar I/O and batched prediction; loads the model once per worker via an initializer; avoids nested thread oversubscription; idempotent atomic chunk outputs with resume; validates and publishes atomically with lineage and deadline alerts. Acceptable: Parallelises with multiprocessing and batches predictions, but ignores reruns, validation or atomic publishing. Red flag: Adds threads to the per-customer loop, or loads the model inside each task, or publishes partial results with no validation.

SR-TH-MLE-06 · Upgrading the embedding model behind a live search index

Hard & Tricky · Theory · Senior (5–8 years) · embeddings, vector search, reindexing, versioning, migrations, retrieval evaluation · 7 min

Question: Product search uses embeddings from model v1 stored in a vector index of 30 million product descriptions. You've fine-tuned v2 with the same 768 dimensions. The plan: switch query embeddings to v2 today, and re-embed products in the background over the next week; "dimensions match, so it's compatible". What happens?

Answer:

Trap: "Same dimension, so v1 and v2 vectors are interchangeable during the migration."

Matching dimensions only means the arrays have the same shape. Each model learns its own coordinate system: dimension 17 in v2 means nothing in particular relative to dimension 17 in v1, even after fine-tuning from the same base, because training rotates and reshapes the space. Cosine similarity between a v2 query and v1 product vectors is close to meaningless. Nothing errors, no exception, no shape mismatch; search relevance silently collapses, and during the week the index holds a mix of v1 and v2 vectors whose scores aren't comparable with each other either.

How to migrate properly (expand/contract for embeddings):

  1. Version vectors explicitly: store the embedding model ID with each collection or index (and ideally on each vector), and have the query path assert that the query model matches the index's model.
  2. Build a complete v2 index side by side: backfill all 30 million products with v2 in a batch job (rate-limited, resumable, idempotent per product), while dual-writing new and updated products to both indexes.
  3. Evaluate before switching: offline recall@k / NDCG on a labelled query set, plus a comparison of top results for frequent queries; then an online A/B test with v2 queries against the v2 index.
  4. Switch queries and index together, atomically, via an alias or config flag; keep v1 (and v1 query embedding) available for rollback until v2 has proven itself, then retire it.
  5. Re-tune everything calibrated on v1 scores: similarity thresholds ("only show results above 0.8"), hybrid-search weights, caches of query embeddings (key them by model version), and anything downstream that stored v1 vectors (deduplication, recommendations).

Cost planning matters at this size: re-embedding 30 million texts is a significant GPU or API bill and takes time, so plan capacity and avoid re-embedding twice by settling the model first.

Follow-ups:

  1. "Couldn't you learn a linear mapping from v1 to v2 space to avoid re-embedding?" It's possible (fit a projection on pairs of v1/v2 embeddings of the same texts), and it can serve as a stopgap, but it loses quality relative to native v2 vectors and must itself be evaluated on retrieval metrics. For a fine-tuned model meant to improve relevance, a full re-embedding is usually what delivers the gain.
  2. "How do you make the 30-million-item backfill safe to stop and restart?" Process products in ordered chunks by ID, write each chunk's vectors with an upsert keyed by product ID and model version, record a checkpoint after each chunk commits, and make the job resume from the last checkpoint. Throttle it so it doesn't starve live indexing, and emit progress metrics so the switch date is predictable.

Scoring: Strong: Explains that embedding spaces aren't comparable across models despite equal dimensions and that failure is silent; versions vectors, builds and evaluates a full v2 index with dual writes, switches atomically with rollback, and re-tunes thresholds and caches. Acceptable: Knows the index must be re-embedded before switching queries, but misses dual writes, evaluation or threshold recalibration. Red flag: Accepts the mixed-index plan because dimensions match, or switches without any retrieval evaluation.

SR-TH-MUT-01 · A team standard for functions that mutate their arguments

Easy · Theory · Senior (5–8 years) · mutability, API design, code review, defensive copying, pandas · 4 min

Question: In review you see def normalise(rows): for r in rows: r["amount"] = round(r["amount"], 2); return rows in a shared utilities module. What's wrong with it, and what standard would you set for the team on functions that mutate their inputs?

Answer:

The function mutates the caller's dicts and also returns them. That's the most misleading combination. The return value suggests "a new, normalised copy", so a caller who writes clean = normalise(raw) and later uses raw for an audit log or a retry gets silently changed data. Callers that share rows with other code (a cache, another task) are affected too.

A reasonable team standard:

  • Default to not mutating inputs in shared code: return new objects ([{**r, "amount": round(r["amount"], 2)} for r in rows]).
  • If a function mutates, make it obvious: a name such as normalise_in_place, a None return (the same convention as list.sort, so x = f(x) is visibly wrong), and a docstring line saying so.
  • Copy at trust boundaries: a class that stores a caller-supplied list or dict should copy it (self._items = list(items)), or the caller can change the object's state later without going through its methods.
  • Signal read-only intent in types: accept Sequence/Mapping instead of list/dict, so mypy flags writes.
  • In pandas, prefer methods that return a new frame (assign, chained methods) over inplace=True, which rarely saves memory and makes data flow harder to follow.

The trade-off is cost: copying a 10-million-row structure per call is expensive. In hot paths, mutation is fine if it's named, documented and the data isn't shared.

Follow-ups:

  1. "How do you write a test that proves a function doesn't mutate its input?" Snapshot the input with copy.deepcopy before the call and assert it's still equal afterwards. For property-based coverage, Hypothesis can generate nested inputs and run the same check.
  2. "Is returning self from mutating methods (fluent style) acceptable?" It's a deliberate convention in builders (query.where(...).limit(...)), and fine when the whole API is consistent about it. Mixing styles in one class (some methods return new objects, some mutate and return self) is the problem, because readers can't tell which is which.

Scoring: Strong: Identifies the mutate-and-return problem and its effect on callers, proposes a clear standard (no mutation by default, naming plus None return, copy at boundaries, read-only types) and weighs the copying cost. Acceptable: Says "don't mutate inputs, return a copy" with sound reasoning, without a broader standard or trade-offs. Red flag: Sees nothing wrong, or proposes copy.deepcopy everywhere as the rule.

SR-TH-MUT-02 · A background task sees the dict as it is when the task runs

Easy & Tricky · Theory · Senior (5–8 years) · aliasing, asyncio, create_task, concurrency, logging, snapshots · 4 min

Question: An order service does asyncio.create_task(publish(event)) to send an event in the background, then keeps updating the same event dict before awaiting anything. Which version of the event gets published?

Answer:

Trap: "The version at create_task time. The task was created with that event, so it has its own copy."

create_task passes a reference to the same dict. The task doesn't start running until the current coroutine yields to the event loop (here, at await t). By then status is already "PAID", so the downstream consumer sees an order as paid before the payment step actually ran. With threads it's the same story, plus a data race: the worker may read the dict while it's half-updated.

The behaviour also changes with the executor. ProcessPoolExecutor.submit pickles the arguments at submit time, which is a snapshot. Switching between thread and process pools therefore changes semantics, not just performance.

The same bug shows up wherever work is deferred:

  • Logging handlers that keep the record and format it later, such as logging.handlers.MemoryHandler or a custom buffering or async handler, log the mutated argument values, not the values at the time of the call.
  • Retry queues and outbox tables that store a reference instead of a serialised payload.

Fix: hand deferred work an immutable snapshot: publish(dict(event)) for a flat dict, copy.deepcopy for nested data, or better, a frozen dataclass or pydantic model built at that point, or serialise to bytes or JSON immediately when the payload is going to a broker anyway.

import asyncio


async def publish(event):
    print("published", event)


async def main():
    event = {"order": "A-101", "status": "PLACED"}
    t = asyncio.create_task(publish(event))
    event["status"] = "PAID"
    await t


asyncio.run(main())

Output:

published {'order': 'A-101', 'status': 'PAID'}

Follow-ups:

  1. "Would asyncio.create_task(publish(dict(event))) fix it even if event had a nested items list?" Only for top-level keys. dict(event) is shallow, so the nested list is shared and a later event["items"].append(...) still leaks into the published event. Use a deep copy, or better, serialise at the boundary.
  2. "How would you detect this kind of bug in a code review?" Look for mutable objects passed to anything deferred (tasks, executors, callbacks, queues, buffered loggers) that the caller keeps modifying afterwards. A good rule is that payloads crossing a concurrency boundary are immutable or serialised.

Scoring: Strong: Predicts the PAID output, explains reference passing plus the task's start being deferred, contrasts with process-pool pickling, and proposes immutable or serialised payloads. Acceptable: Gets the output once they think about when the task runs, and fixes it with a copy. Red flag: Expects PLACED, or thinks tasks receive copies of their arguments.

SR-TH-MUT-03 · Customising deepcopy for objects that hold clients, locks or large shared data

Medium · Theory · Senior (5–8 years) · copy, deepcopy, __deepcopy__, __reduce_ex__, resources, design · 6 min

Question: copy.deepcopy(request) fails with TypeError: cannot pickle '_thread.lock' object because the request object holds a pricing client. How does deepcopy decide how to copy an object, and when and how would you customise it?

Answer:

copy.deepcopy(x, memo) checks, in order:

  1. The memo dict: if id(x) was already copied, it returns that copy. This handles cycles and shared references.
  2. Atomic types (ints, strings, functions, classes) are returned as is.
  3. x.__deepcopy__(memo) if the class defines it.
  4. Otherwise it uses the pickle protocol: copyreg or __reduce_ex__(4), honouring __getstate__/__setstate__, then recursively deep-copies the state.

Step 4 is why anything that can't be pickled can't be deep-copied: locks, sockets, open files, DB connections, generators. The error mentions pickle even though you never pickled anything.

Customise when parts of the object shouldn't be copied at all:

  • Clients, connection pools, locks: share them, or create fresh ones.
  • Large read-only data (a loaded model, a rate table): share, since copying wastes memory and time.
  • Caches: usually reset in the copy.

In __deepcopy__, register the clone in memo before copying children, so cycles back to self resolve to the clone. Deep-copy only the data fields and pass memo along.

The senior-level point is usually design: needing a custom __deepcopy__ is a hint that data and services are mixed in one object. Keep the request as plain data (a dataclass) and inject the client where it's used. Then a default deep copy is correct and cheap.

import copy
import threading


class PricingClient:
    def __init__(self):
        self._lock = threading.Lock()


class QuoteRequest:
    def __init__(self, items, client):
        self.items = items
        self.client = client


req = QuoteRequest([{"sku": "TEA-1", "qty": 2}], PricingClient())
try:
    copy.deepcopy(req)
except TypeError as e:
    print("deepcopy failed:", e)


class SafeQuoteRequest(QuoteRequest):
    def __deepcopy__(self, memo):
        clone = copy.copy(self)                        # shallow: shares the client
        memo[id(self)] = clone
        clone.items = copy.deepcopy(self.items, memo)  # deep-copy only the data
        return clone


r1 = SafeQuoteRequest([{"sku": "TEA-1", "qty": 2}], PricingClient())
r2 = copy.deepcopy(r1)
r2.items[0]["qty"] = 5
print(r1.items[0]["qty"], r2.items[0]["qty"], r1.client is r2.client)

Output:

deepcopy failed: cannot pickle '_thread.lock' object
2 5 True

Follow-ups:

  1. "Why does the custom __deepcopy__ put clone into memo before copying items?" If something inside items refers back to the request, the recursive deep copy would otherwise start copying the request again, recursing forever or producing a second clone. Registering early makes back-references resolve to the clone being built.
  2. "Would __getstate__ returning the state without the client also work?" Yes, it affects both pickle and the default deepcopy path. The copy would lack a client, so __setstate__ must recreate or re-inject one. That's the right choice when the object really is sent across processes; __deepcopy__ is the narrower in-process tool.

Scoring: Strong: Explains memo, __deepcopy__, and the fallback to the pickle protocol (hence the pickle error), writes a correct __deepcopy__ that uses memo, and raises the "separate data from services" design point. Acceptable: Knows to implement __deepcopy__ to share the client but can't explain why the error mentions pickle or how memo works. Red flag: Proposes a try/except around deepcopy or copy.copy everywhere, without seeing the shared-nested-data consequence.

SR-TH-MUT-04 · NumPy slices are views, fancy indexing copies, and in-place ops write through

Medium & Tricky · Theory · Senior (5–8 years) · numpy, views, copies, aliasing, pandas, copy-on-write, memory · 5 min

Question: A pricing job takes window = prices[1:4] and does window -= 2 to "adjust the window", then later picked = prices[[1, 2, 3]]; picked -= 50. Which of these change prices? What is the equivalent trap in pandas, and how do you set a rule for the team?

Answer:

Trap: "Slicing copies, like it does for Python lists, so prices is untouched."

In NumPy, basic slicing returns a view, a new array object over the same memory buffer. window -= 2 is an in-place operation, so it writes through and prices changes. Fancy (integer-list) or boolean indexing returns a copy, so picked -= 50 has no effect on prices. np.shares_memory (or arr.base) tells you which you have. Note that window = window - 2 would rebind window to a new array and leave prices alone. It's the in-place operator that writes through.

Two production consequences:

  • Silent corruption: a helper that "normalises a window" in place modifies the caller's master array.
  • Memory: a small view keeps the whole base array alive. Returning big[:10] from a function that loaded 2 GB pins all 2 GB. Return big[:10].copy() if you keep it.

pandas: whether a selection is a view or a copy has historically been an implementation detail. Chained assignment such as df[df.city == "Pune"]["fee"] = 0 may modify a temporary copy and do nothing, with only a SettingWithCopyWarning. pandas 2.2 offers pd.options.mode.copy_on_write = True, and copy-on-write is the default from pandas 3.0: every derived frame behaves as a copy, and chained assignment never updates the original. Write single-step df.loc[mask, "fee"] = 0.

Team rule: functions don't mutate arrays or frames they receive. If they must, they're named and documented. Use .copy() at boundaries where a slice is stored or returned.

import numpy as np

prices = np.array([100, 102, 105, 103, 99, 98], dtype=np.int64)
window = prices[1:4]          # basic slice -> view
window -= 2
print(prices)

picked = prices[[1, 2, 3]]    # fancy indexing -> copy
picked -= 50
print(prices)
print(np.shares_memory(prices, window), np.shares_memory(prices, picked))

Output:

[100 100 103 101  99  98]
[100 100 103 101  99  98]
True False

Follow-ups:

  1. "Why does NumPy make slices views at all?" Performance: slicing a large array is O(1) with no allocation, and strided views (a[::2], transposes, reshapes) let many operations avoid copying. The cost is that aliasing becomes the caller's responsibility.
  2. "How would you make an array you pass to untrusted code read-only?" Set arr.flags.writeable = False on it (or on a view you hand out). In-place writes then raise ValueError: assignment destination is read-only. It's a cheap guard for shared lookup tables.

Scoring: Strong: Correct for both cases, explains views vs copies and in-place vs rebinding, raises the memory-pinning issue, and knows pandas chained assignment plus copy-on-write. Acceptable: Knows slices are views and fancy indexing copies, but is vague on pandas behaviour. Red flag: Expects NumPy slices to copy like lists, or treats SettingWithCopyWarning as noise to suppress.

SR-TH-MUT-05 · Consistent snapshots of shared state without locking every read

Hard · Theory · Senior (5–8 years) · immutability, snapshots, copy-on-write, threading, MappingProxyType, concurrency design · 8 min

Question: A freight-pricing service keeps a rate card in memory. Hundreds of request threads read it; an admin endpoint updates a few lanes a few times an hour. A request must see one consistent version of the card for its whole duration. How do you design this, and what are the trade-offs?

Answer:

Options, from naive to robust:

  1. Mutable dict, no lock. Readers can see a half-applied multi-key update. And iterating over the dict while a writer adds a key raises RuntimeError: dictionary changed size during iteration.
  2. One lock around every read and write. Correct, but every request contends on the lock, and a request that reads several times can still see two versions unless it holds the lock throughout.
  3. Copy-on-read (dict(card) per request). Consistent per request, but you pay O(n) per request for a rare write.
  4. Copy-on-write snapshot swap (read-copy-update). The current card is an immutable snapshot. A writer, under a writers-only lock, builds a new dict from the old one, applies changes, wraps it read-only and swaps the reference in a single assignment. A reader grabs the reference once at the start of a request and uses that object throughout. Rebinding an attribute is atomic, so readers never see a half-built card and never take a lock.

For this read-heavy pattern, 4 is the right answer. Trade-offs to state:

  • Each write costs O(n) and briefly holds two versions in memory. Batch updates, and for very large or frequently changing state use structural sharing (persistent maps such as pyrsistent) or partition the card.
  • Immutability must be deep. MappingProxyType is a read-only view, so nested lists or dicts inside are still mutable. Store tuples or frozen dataclasses as values.
  • Readers may use a version that is up to one request old. That's usually fine for pricing, but say so, and attach a version ID to responses and logs.
  • It's per process. Across many workers or pods you need a version key in a shared store (Redis, DB) with a cheap "has the version changed?" check.

import threading
from types import MappingProxyType


class RateCard:
    def __init__(self, rates):
        self._snapshot = MappingProxyType(dict(rates))
        self._write_lock = threading.Lock()

    def snapshot(self):
        return self._snapshot                     # one reference read

    def update(self, changes):
        with self._write_lock:                    # serialise writers only
            new = dict(self._snapshot)
            new.update(changes)
            self._snapshot = MappingProxyType(new)  # atomic swap


card = RateCard({"BLR-DEL": 4_500, "BOM-DEL": 3_900})
view = card.snapshot()                  # a request that started earlier
card.update({"BLR-DEL": 5_200})
print(view["BLR-DEL"], card.snapshot()["BLR-DEL"])
try:
    view["BLR-DEL"] = 1
except TypeError as e:
    print("TypeError:", e)

Output:

4500 5200
TypeError: 'mappingproxy' object does not support item assignment

Follow-ups:

  1. "Why does the writer still need a lock if readers don't?" Two concurrent writers would each copy the same old snapshot and the last swap would win, losing the other's update (a lost update). The lock serialises the read-modify-swap so every write builds on the latest version.
  2. "How does this pattern fare on free-threaded (no-GIL) CPython?" The design relies only on publishing a new immutable object through one reference assignment and never mutating a published snapshot. That's the pattern that stays safe without the GIL, unlike code that relied on the GIL to make in-place dict mutations "atomic enough". Readers must still take the reference once per request rather than re-reading card.snapshot() repeatedly.

Scoring: Strong: Compares the options, lands on snapshot swap with a writer lock, explains consistency per request, deep vs shallow immutability, write cost and the multi-process limitation. Acceptable: Proposes a threading.Lock or RLock around reads and writes, which is correct, and can discuss contention when prompted. Red flag: Relies on "the GIL makes dict operations atomic" for multi-key consistency, or deep-copies the card on every request without noticing the cost.

SR-TH-MUT-06 · deepcopy preserves sharing inside the copy; a JSON round-trip doesn't

Hard & Tricky · Theory · Senior (5–8 years) · deepcopy, aliasing, memo, json, configuration · 5 min

Question: A plan-config loader builds plans = {"basic": {"fees": slab}, "pro": {"fees": slab}} with the same slab list. Code that customises a plan does deep = copy.deepcopy(plans) and then deep["pro"]["fees"].append(20). Is deep["basic"] affected? What if the copy had been made with json.loads(json.dumps(plans))?

Answer:

Trap: "deepcopy makes everything independent, so only pro changes."

deepcopy copies the graph, including its shape. Its memo dict maps each original object's id to its copy, so an object reached twice is copied once and both places in the copy point to the same new list. The copy is independent of the original, but its internal sharing mirrors the original's. Appending through deep["pro"] therefore also shows up in deep["basic"]. The original plans is untouched. This is the same mechanism that lets deepcopy handle cycles.

A JSON round-trip has no memo. It serialises each occurrence separately, so the copy has two independent lists and only pro changes. JSON has its own losses, though: tuples become lists, non-string dict keys become strings, and Decimal, datetime and sets either fail or need custom encoding. pickle behaves like deepcopy here (it has a memo too).

In a config system this causes "editing one plan changed another" bugs that only happen when the loader happened to reuse an object, for example a shared default slab, YAML anchors and aliases, or a module-level constant.

Fixes: build per-plan structures explicitly ({"fees": list(slab)}), use immutable values (a tuple slab can't be appended to, so sharing is harmless), or treat configs as read-only and produce modified versions with functions that copy only the path they change.

import copy
import json

slab = [0, 5, 10]
plans = {"basic": {"fees": slab}, "pro": {"fees": slab}}

deep = copy.deepcopy(plans)
deep["pro"]["fees"].append(20)
print(deep["basic"]["fees"])

via_json = json.loads(json.dumps(plans))
via_json["pro"]["fees"].append(20)
print(via_json["basic"]["fees"])
print(plans["basic"]["fees"])

Output:

[0, 5, 10, 20]
[0, 5, 10]
[0, 5, 10]

Follow-ups:

  1. "YAML anchors (&default / *default) appear in your configs. How do they interact with this?" PyYAML loads an alias as the same Python object as its anchor, so every plan that uses *default shares one dict or list. Mutating one changes all of them, and deepcopy keeps that sharing. Either deep-copy each subtree separately after loading, or treat the loaded config as read-only.
  2. "When is preserving shared references the behaviour you want?" For object graphs where identity is meaningful, such as several orders referencing the same Customer object, or a graph with cycles. A copy that duplicated the customer per order would break invariants ("update the customer once").

Scoring: Strong: Correctly predicts both outputs, explains memo and that deepcopy preserves aliasing within the copy, notes JSON's other losses, and gives a structural fix. Acceptable: Gets the deepcopy output after reasoning about how cycles must be handled. Red flag: Insists both copies leave basic unchanged, or recommends a JSON round-trip as the general-purpose deep copy.

SR-TH-NUM-01 · Splitting an amount so the parts add up exactly

Easy · Theory · Senior (5–8 years) · numbers, money, integer arithmetic, rounding, allocation · 4 min

Question: A marketplace splits a ₹100.00 order discount across 3 line items, and a settlement report later sums the parts. With round(total / 3, 2) per item, the parts add to ₹99.99 and reconciliation flags a 1-paisa difference every day. How should allocation be done?

Answer:

Rounding each share independently can never guarantee that the shares add up to the total; the rounding errors accumulate. Allocation has to be done so that the sum is exact by construction:

  1. Work in integer minor units (paise), not floats or rupee decimals.
  2. Give each part its floor share: total * weight // sum(weights).
  3. The leftover is total - sum(floor shares), always fewer paise than there are parts. Distribute it one paisa at a time by a deterministic rule: largest remainder first (most accurate proportionally), with a fixed tie-breaker (input order, or line ID).

Things a senior engineer pins down:

  • Determinism: the same input must always give the same split, across services and reruns, or refunds of a single line won't match what was charged. Write the rule in the spec, not just the code.
  • Who gets the extra paisa can matter legally or commercially (e.g. GST per line on invoices). Agree with finance; "largest remainder, ties to the first line" is a common choice.
  • Refunds and partial returns must reuse the allocated per-line amounts, not recompute from the total.
  • The same technique applies to splitting a payout across sellers, EMI schedules (the last instalment absorbs the difference), and pro-rating subscriptions.

def allocate(total_paise: int, weights: list[int]) -> list[int]:
    """Split total across weights so the parts always sum to total (largest remainder)."""
    wsum = sum(weights)
    base = [total_paise * w // wsum for w in weights]
    remainders = [total_paise * w % wsum for w in weights]
    short = total_paise - sum(base)
    # the leftover paise go to the largest remainders; ties go to the earlier line
    order = sorted(range(len(weights)), key=lambda i: (-remainders[i], i))
    for i in order[:short]:
        base[i] += 1
    return base

naive = [round(10000 / 3) for _ in range(3)]
print("naive :", naive, "sum", sum(naive))
parts = allocate(10000, [1, 1, 1])
print("alloc :", parts, "sum", sum(parts))
parts = allocate(99999, [50, 30, 20])   # ₹999.99 split 50/30/20
print("alloc :", parts, "sum", sum(parts))

Output:

naive : [3333, 3333, 3333] sum 9999
alloc : [3334, 3333, 3333] sum 10000
alloc : [49999, 30000, 20000] sum 99999

Follow-ups:

  1. "Why not just give the whole rounding difference to the last item?" It works and is simple, but the last item can absorb a noticeably skewed amount when there are many parts, and its share depends on input order. Largest remainder keeps every part within one paisa of its exact proportional share.
  2. "How would you property-test allocate?" For random totals and weights: the parts sum to the total, every part is within one paisa of total * w / wsum, the result is identical across repeated calls, and zero weights get zero.
  3. "What if weights are prices in paise that are themselves large?" Python ints are arbitrary precision, so total * w can't overflow. In NumPy or a database with fixed-width integers, check the product's range or use a wider type.

Scoring: Strong: Uses integer paise, floor shares plus deterministic distribution of the remainder, explains why independent rounding can't work, and covers determinism, refunds reusing allocations, and agreeing the rule with finance. Acceptable: Gives the remainder to one item to make the sum match, using integers. Red flag: Suggests more decimal places, or adjusting the report to tolerate the difference.

SR-TH-NUM-02 · The decimal context is per thread

Easy & Tricky · Theory · Senior (5–8 years) · numbers, decimal, rounding, threads, contextvars · 5 min

Question: At startup the billing service sets decimal.getcontext().rounding = ROUND_HALF_UP because finance wants half-up rounding for GST. Invoices generated through the API (a thread pool) sometimes differ by a paisa from the same invoice generated in the nightly script. Why?

Answer:

Trap: "getcontext() is the global decimal configuration, so setting it once at startup applies everywhere."

The decimal context is per thread (and, since it's stored in a ContextVar, per async context). A new thread does not inherit the context of the thread that created it; it starts from a fresh copy of decimal.DefaultContext (precision 28, ROUND_HALF_EVEN). So:

  • the nightly script, running in the main thread, rounds half-up;
  • the API's worker threads silently use banker's rounding: 18.405 becomes 18.40 instead of 18.41.

Fixes, best first:

  • Pass rounding explicitly where money is rounded: amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) through one shared helper. Explicit beats ambient configuration, and reviewers can see it.
  • If you need a whole context, apply it locally: with decimal.localcontext(money_ctx): around the calculation.
  • Setting decimal.DefaultContext fields at startup, before any threads are created, also works for new threads, but it's global, order-dependent and easy to break.

This is the behaviour on 3.12. Python 3.14 adds an option for threads to inherit the caller's context (on by default only in the free-threaded build), another reason not to rely on ambient decimal state.

import decimal
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal, ROUND_HALF_UP

decimal.getcontext().rounding = ROUND_HALF_UP     # "app startup"

def gst(amount: str) -> Decimal:
    return (Decimal(amount) * Decimal("0.18")).quantize(Decimal("0.01"))

def gst_explicit(amount: str) -> Decimal:
    return (Decimal(amount) * Decimal("0.18")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

print("main thread :", gst("102.25"), decimal.getcontext().rounding)
with ThreadPoolExecutor(max_workers=1) as pool:
    print("worker      :", *pool.submit(lambda: (gst("102.25"), decimal.getcontext().rounding)).result())
    print("explicit    :", pool.submit(gst_explicit, "102.25").result())

Output:

main thread : 18.41 ROUND_HALF_UP
worker      : 18.40 ROUND_HALF_EVEN
explicit    : 18.41

Follow-ups:

  1. "Do asyncio tasks inherit the decimal context set in the parent task?" Yes: a task copies the current contextvars context when it's created, and the decimal context lives in a ContextVar. Note the copy holds a reference to the same Context object, so a task that mutates getcontext() in place can affect others; localcontext() avoids that.
  2. "Why is half-even the default at all?" It's unbiased over many roundings (ties go up and down equally often), which is what IEEE 754 and most statistical work want. Finance and tax rules often specify half-up, so the rule must come from the spec, not the default.
  3. "How do you test for this class of bug?" Run money calculations in a worker thread in tests (or through the real thread-pool path), and include tie cases like x.xx5 that differ between rounding modes.

Scoring: Strong: Knows the context is per thread/context and new threads start from DefaultContext, shows the tie case, and fixes with explicit rounding in quantize (or localcontext), noting DefaultContext as a fragile alternative. Acceptable: Suspects the rounding mode differs between paths but can't explain the threading mechanism. Red flag: Blames float conversion or suggests rounding twice.

SR-TH-NUM-03 · Downcasting a feature table to float32 to save memory

Medium · Theory · Senior (5–8 years) · numbers, float32, numpy, pandas, precision, memory · 6 min

Question: A feature pipeline for a credit-risk model holds a 40 GB pandas DataFrame. Someone proposes df = df.astype("float32") across the board to halve memory. What can break, and how would you decide column by column?

Answer:

float32 has a 24-bit significand: about 7 significant decimal digits, versus about 15–16 for float64. Integers are exact only up to 2²⁴ = 16,777,216. Halving memory is real, but it changes values in ways that depend on magnitude:

  • Money: ₹1,23,45,678.91 needs 10 significant digits; in float32 it becomes 12345679.0, so the paise and even rupees are gone. Totals and reconciliations drift.
  • Timestamps and IDs: epoch seconds (~1.79 × 10⁹) are spaced 128 apart in float32, so two events a minute apart can become the same value and diff() is 0. Large integer IDs (customer IDs, phone numbers) collide, breaking joins.
  • Accumulations: sums and means over millions of rows accumulate error faster; variance computed as E[x²] − E[x]² can go negative.
  • Fine for: features already scaled or normalised (z-scores, ratios, probabilities, embeddings), where 7 digits exceed the signal's real precision. Most ML models train in float32 anyway.

How I'd decide:

  • Classify columns: identifiers (keep as int64 or strings/categoricals, never floats), money and counts (integers in minor units, or float64), timestamps (datetime64[ns]), continuous features (candidates for float32).
  • Check each candidate: the max absolute value and the precision needed (np.finfo(np.float32)), and compare model metrics and aggregate checks before and after on a sample.
  • Get the bigger savings elsewhere first: category dtype for repeated strings, integer downcasting with pd.to_numeric(..., downcast="integer"), loading only needed columns, Arrow-backed dtypes, or processing in chunks.
  • Keep accumulations (sums over many rows) in float64 even when storage is float32 (arr.sum(dtype=np.float64)).

import numpy as np

amount = np.float64(12_345_678.91)                 # ₹1.23 crore, in rupees
print(float(np.float32(amount)), float(np.float32(amount)) - float(amount))

ts = np.array([1_791_100_801, 1_791_100_859])       # two events 58 s apart, epoch seconds
ts32 = ts.astype(np.float32)
print(ts32.astype(np.int64), np.diff(ts32))

print(np.float32(16_777_217) == np.float32(16_777_216))
print(np.finfo(np.float32).precision, np.finfo(np.float64).precision)

Output:

12345679.0 0.08999999985098839
[1791100800 1791100800] [0.]
True
6 15

Follow-ups:

  1. "np.finfo(np.float32).precision says 6, but you said about 7 digits. Which is right?" Both, for different questions: precision is the number of decimal digits that are always preserved round-trip (6); the significand carries about 7.2 decimal digits of information. For planning, assume 6 safe digits.
  2. "What about float16 or bfloat16 for features?" float16 has only ~3 decimal digits and a max of 65,504, and bfloat16 has float32's range but only ~2–3 digits. They're for model weights and activations on accelerators, not for storing tabular features.
  3. "Where would you enforce these dtype decisions?" In the data contract or schema (pandera, pyarrow schema, Pydantic for configs), checked when data is written and read, so a later astype can't silently change a money or ID column.

Scoring: Strong: Quantifies float32 precision (~7 digits, 2²⁴ exact integers), gives concrete breakages (money, timestamps, IDs, accumulation), proposes column classification with checks before/after, and points to bigger memory wins (categoricals, int downcasting, chunking). Acceptable: Knows float32 loses precision and avoids it for money, but has no systematic way to decide. Red flag: Applies float32 to everything, or converts ID columns to floats.

SR-TH-NUM-04 · A distributed float sum that changes between runs

Medium & Tricky · Theory · Senior (5–8 years) · numbers, floating point, associativity, distributed aggregation, reconciliation · 5 min

Question: A daily revenue job sums transaction amounts (floats) per merchant across partitions in parallel. Re-running it on identical input sometimes gives totals that differ in the last digits, and an equality check against the previous run fails. The team suspects a data bug. Is there one?

Answer:

Trap: "Addition is commutative and associative, so the order of summation can't change the result."

Floating-point addition is commutative but not associative: (a + b) + c can differ from a + (b + c) because every intermediate result is rounded to 53 bits. A parallel or distributed sum adds values in an order that depends on partitioning, thread scheduling and which partial results finish first; Spark, Dask, database SUM over DOUBLE, and multi-threaded BLAS or GPU reductions all do this. The same data then gives slightly different totals run to run. In bad cases (large values cancelling, like ₹1e16-scale ledger balances with small adjustments) the difference isn't in the last digit at all. So there's no data bug, but there is a design bug: using floats where reproducibility matters.

Note that Python 3.12's built-in sum() uses compensated summation, which hides much of this in single-process tests; the distributed engine doesn't.

Fixes depending on the need:

  • Money: sum integer paise (or DECIMAL/NUMERIC columns in SQL and Spark). Integer addition is associative, so any order gives the identical result.
  • Floats that must be reproducible: fix the reduction order (deterministic partitioning, partials combined in a fixed order), and use exactly rounded per-partition sums (math.fsum) to shrink the error. Even then, combining rounded partials can differ from one fsum over all values, so bit-exact totals need integer or decimal representations.
  • Comparisons between runs: compare with a tolerance appropriate to magnitude (math.isclose(a, b, rel_tol=1e-12)), and treat exact equality checks on float aggregates as a bug.

import math
from functools import reduce
from operator import add

def naive_total(values):            # what a plain loop, Spark or SQL SUM over doubles does
    return reduce(add, values, 0.0)

amounts = [1e16, 1.0, -1e16, 0.1, 0.2, 0.3]
p1 = naive_total(amounts[:3]) + naive_total(amounts[3:])     # one partitioning
p2 = naive_total(amounts[::2]) + naive_total(amounts[1::2])  # same rows, different split
print(p1, p2, p1 == p2)
print(math.fsum(amounts))
print((0.1 + 0.2) + 0.3, 0.1 + (0.2 + 0.3))

Output:

0.6000000000000001 1.6 False
1.6
0.6000000000000001 0.6

Follow-ups:

  1. "Why did the first partitioning lose the 1.0 entirely?" Near 1e16 adjacent doubles are 2 apart, so 1e16 + 1.0 rounds back to 1e16; subtracting 1e16 then gives 0. In the other split, 1e16 and -1e16 cancel first and the 1.0 survives.
  2. "Would converting to Decimal in Python fix the Spark job?" Only if the engine also aggregates in decimal: cast the column to DecimalType (or store paise as LongType) so the cluster sums exactly. Converting after a float aggregation is too late.
  3. "Can the same non-determinism appear on a single machine?" Yes: multi-threaded BLAS (dot products, matrix multiplies), GPU reductions using atomics, and code whose summation order depends on memory layout (NumPy's pairwise summation over contiguous vs strided data) can all give slightly different results for the same values.

Scoring: Strong: Explains non-associativity due to per-step rounding, links it to partition/scheduling order in distributed engines, shows a cancellation case, and fixes with integer/decimal aggregation for money plus tolerance-based comparison for genuine floats. Acceptable: Knows float sums are order-dependent but proposes only rounding the output. Red flag: Insists the data changed between runs, or proposes retrying until the totals match.

SR-TH-NUM-05 · Making float leaks into money code fail loudly

Hard · Theory · Senior (5–8 years) · numbers, decimal, FloatOperation, money, standards, validation · 7 min

Question: The billing codebase is supposed to use Decimal for money, but floats keep leaking in (a JSON field parsed as float, Decimal(0.1), comparisons like amount > 0.0), producing paisa differences that are found weeks later in reconciliation. As the owner, how do you make these leaks fail fast instead of producing slightly wrong numbers?

Answer:

Layer the defences so a float can't enter, and if one does, it fails at the first operation:

1. Stop floats at the boundaries.

  • JSON: parse money as Decimal (json.loads(s, parse_float=Decimal)) or, better, carry integer paise or decimal strings ("499.00") in API contracts.
  • Pydantic: declare fields as Decimal with max_digits/decimal_places, but know that a JSON number sent for a Decimal field goes through a float on the way in (in Pydantic 2.x, 12345678901234567890.12 arrives as Decimal('12345678901234567000')), and strict mode doesn't change that for JSON input. So the contract should carry money as a string ("499.00") or integer paise, which Pydantic converts exactly.
  • Database: NUMERIC(12, 2) or BIGINT paise columns; drivers then return Decimal or int, never float.

2. Make the decimal context trap mixing. The FloatOperation signal is off by default. In a context with traps=[FloatOperation, ...]:

  • Decimal(0.1) (constructing from a float) raises instead of silently producing 0.1000000000000000055511151231257827...;
  • ordering comparisons with floats (amount < 500.0) raise;
  • equality comparisons (Decimal("0.5") == 0.5) do not raise (they only set the flag), and explicit Decimal.from_float() is allowed, so intentional conversions remain possible. Apply the context with decimal.localcontext(MONEY) in the money module's entry points; remember contexts are per thread, so setting it once in main doesn't cover worker threads. Also trap InvalidOperation and DivisionByZero, and use explicit rounding in quantize.

3. Make the types say it. Money functions annotated with Decimal (or a Money value type), so mypy flags float arguments; a lint rule against float( and round( in the billing package.

4. Detect what slips through. Reconciliation jobs that compare totals exactly (integers or decimals, no tolerance) and alert on any difference, plus property tests with tie cases (x.xx5).

import decimal
from decimal import Decimal

MONEY = decimal.Context(prec=28, rounding=decimal.ROUND_HALF_UP,
                        traps=[decimal.FloatOperation, decimal.InvalidOperation, decimal.DivisionByZero])

def apply_discount(price: Decimal, pct) -> Decimal:
    with decimal.localcontext(MONEY):
        return (price * (1 - Decimal(pct) / 100)).quantize(Decimal("0.01"))

print(apply_discount(Decimal("499.00"), "12.5"))
try:
    apply_discount(Decimal("499.00"), 12.5)
except decimal.FloatOperation:
    print("FloatOperation: float passed as discount")
with decimal.localcontext(MONEY):
    try:
        Decimal("499.00") < 500.0
    except decimal.FloatOperation:
        print("FloatOperation: ordering comparison with a float")
    print("equality with a float does not trap:", Decimal("0.5") == 0.5)
    print("explicit conversion is allowed:", Decimal.from_float(12.5))

Output:

436.63
FloatOperation: float passed as discount
FloatOperation: ordering comparison with a float
equality with a float does not trap: True
explicit conversion is allowed: 12.5

Follow-ups:

  1. "Why doesn't equality with a float trap?" The decimal spec makes equality comparisons between Decimal and float exact and quiet so that mixed-type containers and hashing keep working (Decimal("0.5") == 0.5 and their hashes agree). The FloatOperation flag is still set, so you can check getcontext().flags[FloatOperation] in tests.
  2. "Isn't integer paise simpler than all of this?" For storage and transport, often yes: integers are exact, fast and language-neutral. You still need decimal arithmetic for percentages, tax rates and pro-rating, followed by explicit rounding back to paise, so the trap still has a place in the calculation code.
  3. "A third-party SDK returns amounts as floats. What do you do?" Convert once, at the adapter, with Decimal(str(value)) (the shortest repr, which is what the provider almost certainly meant) and quantize to paise; never let the float travel further. Log or alert if the value has more decimal places than the currency allows.

Scoring: Strong: Layers boundary parsing (JSON, Pydantic strict, DB types), the FloatOperation trap with its exact semantics (construction and ordering raise, equality and from_float don't), per-thread context application, typing/linting, and exact reconciliation. Acceptable: Uses Decimal at boundaries and in models, but doesn't know about FloatOperation or how to enforce it. Red flag: Suggests rounding floats to 2 decimals as the standard, or tolerating small differences in reconciliation.

SR-TH-NUM-06 · Equal Decimals that produce different idempotency keys

Hard & Tricky · Theory · Senior (5–8 years) · numbers, decimal, idempotency, string formatting, payments · 6 min

Question: A payment service builds idempotency keys as f"{order_id}:{amount}" with amount a Decimal. A customer was charged twice for ₹500 within a minute. Logs show keys A1:500.0 and A1:500.00, and the code compares amounts with == elsewhere without issue. What happened, and how should keys be built?

Answer:

Trap: "Equal Decimals are the same value, so they format the same way."

Decimal preserves exponent (scale) as part of the value's representation: Decimal("500.0") and Decimal("500.00") are numerically equal (== is True, and their hashes agree, so they collide as dict keys), but str() keeps the trailing zeros: "500.0" vs "500.00". The scale depends on where the value came from: one request path parsed "500.0" from a mobile client, another computed price * qty and got two decimal places. So the "same" charge produced two keys and the gateway processed both.

The tempting fix makes it worse: amount.normalize() strips trailing zeros but switches to scientific notation for some values: Decimal("500.00").normalize() is Decimal("5E+2"), so keys become A1:5E+2, which surprises everyone and still varies across code paths that forget to call it.

Build keys from a canonical representation:

  • Quantize to the currency's minor unit with explicit rounding, then format: amount.quantize(Decimal("0.01")), f"{amount:.2f}" on a Decimal, or best, convert to integer paise: int(amount * 100) after quantizing.
  • Include the currency, and better still use a client-supplied idempotency key or a server-side payment_intent_id rather than deriving keys from amounts at all; amounts can legitimately repeat (two genuine ₹500 orders).
  • Apply the same canonicalisation wherever amounts become strings for comparison: cache keys, dedup hashes, signature payloads (HMAC over a JSON body with 500.0 vs 500.00 gives different signatures).

from decimal import Decimal

a = Decimal("500.0")          # parsed from a mobile client's JSON string
b = Decimal("500.00")         # computed as price * qty
print(a == b, hash(a) == hash(b), len({a, b}))
print(f"A1:{a}", f"A1:{b}")
print(b.normalize(), Decimal("12.50").normalize())
q = Decimal("0.01")
print(f"A1:{a.quantize(q)}", f"A1:{b.quantize(q)}")
print(f"A1:{int(a.quantize(q) * 100)}paise")

Output:

True True 1
A1:500.0 A1:500.00
5E+2 12.5
A1:500.00 A1:500.00
A1:50000paise

Follow-ups:

  1. "How would you make Decimal formatting never use scientific notation?" Use format specs rather than str(): f"{d:f}" always gives fixed-point (Decimal("5E+2") → "500"), and f"{d:.2f}" fixes the number of places. str() and normalize() follow the decimal spec's to-scientific-string rules.
  2. "Why do Decimal("500.0") and Decimal("500.00") have the same hash if they're different representations?" Hashing must agree with equality, and they're equal, so Python hashes Decimals by numeric value (consistent with int and float too: hash(Decimal(500)) == hash(500)). It's string conversion, not hashing, that exposes the scale.
  3. "Besides canonical keys, what else should protect against double charges?" A unique constraint on the idempotency key in the payments table, idempotency keys passed to the gateway, and a check for an existing in-flight or successful payment for the same order before creating a new one.

Scoring: Strong: Explains that Decimal keeps scale so equal values stringify differently, warns that normalize() produces exponent notation, canonicalises via quantize/paise, and questions amount-derived keys in favour of client or intent IDs; generalises to signatures and cache keys. Acceptable: Spots the trailing-zero difference and quantizes, but suggests normalize() or doesn't question the key design. Red flag: Blames the gateway or a race condition and proposes a lock, without noticing the different keys.

SR-TH-OOP-01 · Inheritance or composition for pluggable payment providers

Easy · Theory · Senior (5–8 years) · OOP, composition, inheritance, protocols, design · 4 min

Question: The payments service supports Razorpay and a bank's UPI API, with a third provider coming. The current design is a BaseGateway class with 15 methods, half of them overridden or stubbed with raise NotImplementedError in each subclass. How would you restructure it, and when is inheritance still the right tool?

Answer:

The smell is that subclasses inherit implementation they don't want, so every change to the base can break a provider, and nobody can tell which methods a provider really supports. I'd move to composition around a small interface:

  • Define the narrow contract the service actually needs, e.g. a Protocol (or ABC) with create_payment, fetch_status, refund. Optional capabilities (mandates, payouts) become separate small interfaces a provider may or may not implement, instead of stubbed methods.
  • Shared behaviour (signing requests, retries, idempotency keys, logging) moves into collaborator objects passed in: an HttpClient, a Signer, a RetryPolicy. Each provider class has these; it doesn't inherit them.
  • The service depends on the interface and receives a provider instance (constructor injection), so tests pass a fake that satisfies the Protocol without any base class.

Inheritance is still right when:

  • the subclass truly is a specialisation and every inherited method remains correct (the Liskov test: code written for the base works unchanged with the subclass);
  • a framework is designed around it (Django models, Exception hierarchies, unittest.TestCase), or an ABC provides a template method with small, well-documented hooks;
  • the hierarchy is shallow (one or two levels) and owned by one team.

Rule of thumb for a team: inherit interfaces freely, inherit implementation reluctantly, and never just to reuse a couple of helper methods.

Follow-ups:

  1. "How do you stop providers drifting apart once there's no shared base class?" A shared contract test suite, parametrised over every provider implementation (with recorded or sandbox responses), that checks the interface behaviour: idempotent refunds, status mapping, error types. Plus type checking against the Protocol.
  2. "What's wrong with raise NotImplementedError stubs in the base?" Failures surface only at runtime, on the code path that calls the missing method, often in production. An ABC with @abstractmethod fails at instantiation; a Protocol fails at type-check time; separate capability interfaces make support explicit.
  3. "When would you keep an ABC instead of a Protocol?" When you own all implementations and want shared concrete behaviour plus a runtime error for incomplete subclasses, e.g. a template method that calls abstract hooks.

Scoring: Strong: Diagnoses inherited-but-unwanted implementation, proposes a narrow interface with injected collaborators and capability interfaces, explains testing with fakes, and states when inheritance is still appropriate (LSP, frameworks, shallow template methods). Acceptable: Says "prefer composition" and sketches an interface but can't say when inheritance is justified. Red flag: Proposes a deeper hierarchy or mixins for each feature combination, or a big if/else on provider name.

SR-TH-OOP-02 · super() calls the next class in the MRO, not the parent

Easy & Tricky · Theory · Senior (5–8 years) · OOP, super, MRO, multiple inheritance, mixins · 5 min

Question: RefundHandler(AuthMixin, AuditMixin) combines two mixins; both inherit from Handler, and each mixin's handle() does its own work then calls super().handle(req). A teammate expects AuthMixin's super() to call Handler.handle, skipping the audit. What actually runs?

Answer:

Trap: "super() means the parent class, so AuthMixin.handle calls Handler.handle directly."

super() means the next class after the current one in the MRO of the instance's type, not the class's own base. For RefundHandler, C3 linearisation gives RefundHandler → AuthMixin → AuditMixin → Handler → object. So AuthMixin.handle calls AuditMixin.handle, which calls Handler.handle: every class in the chain runs exactly once, in MRO order. The same AuthMixin used alone has the MRO AuthMixin → Handler → object, so its super() goes straight to Handler. What super() resolves to depends on the final class, not on where the method is written.

This is what makes cooperative multiple inheritance work, and it imposes rules:

  • Every class in the chain calls super() (including __init__), otherwise the chain stops early and later mixins silently never run.
  • Methods in the chain need compatible signatures: accept **kwargs and pass on what they don't consume, since you don't know who's next.
  • A shared root (Handler) terminates the chain without calling super() further.
  • Order in the class statement is behaviour: (AuthMixin, AuditMixin) authenticates before auditing; swapping them changes the order.

As a team standard I keep mixins small, stateless where possible, and documented with "call super()"; anything more complex gets composition instead.

class Handler:
    def handle(self, req):
        print("Handler.handle")
        return req

class AuthMixin(Handler):
    def handle(self, req):
        print("AuthMixin.handle")
        return super().handle(req)

class AuditMixin(Handler):
    def handle(self, req):
        print("AuditMixin.handle")
        return super().handle(req)

class RefundHandler(AuthMixin, AuditMixin):
    pass

print([c.__name__ for c in RefundHandler.__mro__])
RefundHandler().handle({"order": "A1"})
print("---")
AuthMixin().handle({})

Output:

['RefundHandler', 'AuthMixin', 'AuditMixin', 'Handler', 'object']
AuthMixin.handle
AuditMixin.handle
Handler.handle
---
AuthMixin.handle
Handler.handle

Follow-ups:

  1. "What happens if AuthMixin.handle calls Handler.handle(self, req) explicitly instead of super()?" It skips AuditMixin entirely in RefundHandler: audit never runs. Hard-coding the parent breaks cooperation, which is why explicit parent calls in a multiple-inheritance hierarchy are a review flag.
  2. "When does Python refuse to create a class because of the MRO?" When no consistent C3 linearisation exists, e.g. class X(A, B) and class Y(B, A) and then class Z(X, Y): Python raises TypeError: Cannot create a consistent method resolution order (MRO).
  3. "Why do __init__ methods in mixins usually take **kwargs?" Because the next __init__ in the MRO may belong to a class the mixin author never saw; each class pops its own arguments and passes the rest along with super().__init__(**kwargs).

Scoring: Strong: States that super() follows the instance type's MRO, derives the order for this example, and lists the cooperative rules (everyone calls super, compatible signatures, base order matters). Acceptable: Gets the call order right by knowing the MRO, but can't explain why the same mixin behaves differently in another class. Red flag: Insists super() always calls the direct parent.

SR-TH-OOP-03 · Designing classes in a library other teams depend on

Medium · Theory · Senior (5–8 years) · OOP, API design, dataclasses, backward compatibility, libraries · 6 min

Question: You own an internal pricing-sdk used by 15 services. Its main type is a Quote class with a constructor, a few methods and several attributes. What design choices keep it easy to evolve without breaking its users?

Answer:

Every public name, attribute and argument position becomes a promise, so I keep the public surface small and explicit:

  • Define what's public. Underscore-prefix internals, list exports in __all__, re-export public types from the package root, and say in the docs that anything else can change. Return your own types or standard ones (Decimal, datetime), never objects from an internal dependency that you may replace.
  • Keyword-only arguments for anything beyond one or two obvious parameters (def __init__(self, sku, *, pincode, currency="INR") or @dataclass(kw_only=True)). Callers then can't depend on argument order, so you can add parameters (with defaults) and reorder them safely.
  • Immutable value objects where possible (@dataclass(frozen=True, slots=True)): a Quote that can't be modified after creation can be cached, shared across threads and compared safely. Changes go through dataclasses.replace() or methods returning new objects.
  • Additive evolution. New fields get defaults; new behaviour goes behind new methods or parameters. Removing or renaming goes through a deprecation cycle: keep the old name working, emit DeprecationWarning (or warnings.deprecated from 3.13 / typing_extensions), document the removal version, then remove in the next major version under semantic versioning.
  • Don't invite subclassing by accident. Subclassing makes every internal method call a public contract (the "fragile base class" problem). Mark classes @final for type checkers if they're not meant to be extended, and offer hooks or callbacks if extension is needed.
  • Useful dunders: a __repr__ that helps in logs, __eq__/__hash__ that match value semantics, and no surprising __bool__/__len__.
  • Typed and checked: ship a py.typed marker so users' type checkers see the annotations, and test the public API (including deprecations) in CI.

Follow-ups:

  1. "You need to rename Quote.amount to Quote.total_paise. How do you roll it out?" Add total_paise, keep amount as a property that returns it and emits DeprecationWarning, release a minor version, track remaining usage (warnings in logs, code search across the 15 repos), and remove amount in the next major version.
  2. "Why are frozen dataclasses with slots=True attractive for a value type, and what's the catch?" Immutable, hashable, smaller and faster attribute access. The catch: no __dict__, so users can't attach ad-hoc attributes, cached_property doesn't work, and adding slots=True later is itself a breaking change if someone relied on __dict__.
  3. "Is adding a new required constructor argument ever acceptable?" Only in a major version. In a minor version add it with a default (or as an optional keyword), possibly warning when it's omitted, then make it required later.

Scoring: Strong: Covers an explicit public surface, keyword-only arguments, immutable value objects, additive evolution with deprecation cycles and semver, avoiding accidental subclassing, and shipping types. Acceptable: Mentions underscores and defaults for new arguments but has no deprecation strategy. Red flag: Treats every attribute as public and changes signatures freely, or exposes internal dependency objects.

SR-TH-OOP-04 · What frozen=True does and doesn't guarantee

Medium & Tricky · Theory · Senior (5–8 years) · OOP, dataclasses, immutability, hashing, value objects · 5 min

Question: A payouts team models PayoutBatch(batch_id: str, order_ids: list[str]) as @dataclass(frozen=True) "so batches can't change after approval", and plans to use batches as keys in a dedup set. Does frozen=True deliver that?

Answer:

Trap: "frozen=True makes the object immutable and hashable."

frozen=True only makes attribute assignment on the instance raise FrozenInstanceError. It is shallow:

  • Mutable fields stay mutable. batch.order_ids.append("A3") works. An "approved" batch can gain orders after approval, which is precisely the invariant the team wanted.
  • Hashing fails. frozen=True with eq=True generates __hash__ from the field values, and hashing the tuple of fields hashes the list, so hash(batch) raises TypeError: unhashable type: 'list', at the first set.add, not at construction.
  • It's bypassable. object.__setattr__(batch, "batch_id", ...) still works (that's how the generated __init__ sets fields), so it's a guard against accidents, not a security boundary.

To get real value-object semantics:

  • Use immutable field types: tuple[str, ...] or frozenset[str] instead of list; convert in __post_init__ (via object.__setattr__) if callers pass lists.
  • Validate invariants in __post_init__ so an invalid batch can't be constructed.
  • If a field must be mutable or unhashable, exclude it from hashing/equality with field(compare=False) / hash=False, and accept that it's not part of the identity.
  • Pydantic models with frozen=True have the same shallowness for nested lists.

from dataclasses import dataclass, FrozenInstanceError

@dataclass(frozen=True)
class PayoutBatch:
    batch_id: str
    order_ids: list[str]

b = PayoutBatch("PB-7", ["A1", "A2"])
try:
    b.batch_id = "PB-8"
except FrozenInstanceError as exc:
    print("blocked:", exc)
b.order_ids.append("A3")
print(b)
try:
    print(hash(b))
except TypeError as exc:
    print("hash failed:", exc)
object.__setattr__(b, "batch_id", "PB-9")
print(b.batch_id)

Output:

blocked: cannot assign to field 'batch_id'
PayoutBatch(batch_id='PB-7', order_ids=['A1', 'A2', 'A3'])
hash failed: unhashable type: 'list'
PB-9

Follow-ups:

  1. "What does a non-frozen @dataclass (default eq=True) do to __hash__?" It sets __hash__ = None, making instances unhashable, because a mutable object whose equality depends on its fields must not be hashable. unsafe_hash=True forces a hash, which is a bug waiting to happen if fields change while the object is in a set.
  2. "How would you convert a list argument to a tuple in a frozen dataclass?" In __post_init__: object.__setattr__(self, "order_ids", tuple(self.order_ids)), and annotate the field as tuple[str, ...] so type checkers steer callers correctly.
  3. "Why does it matter for caching that a value object is deeply immutable?" Cached objects are shared between callers; if any caller can mutate a nested list, it changes the cached value for everyone (and its hash, if it's a key), producing bugs far from the mutation.

Scoring: Strong: Explains that frozen is shallow and assignment-only, predicts the TypeError on hashing a list field, knows the object.__setattr__ bypass, and fixes with immutable field types plus __post_init__ validation. Acceptable: Knows nested lists stay mutable but doesn't predict the hashing failure. Red flag: Believes frozen dataclasses are deeply immutable or always hashable.

SR-TH-OOP-05 · Descriptors, attribute lookup order, and cached_property

Hard · Theory · Senior (5–8 years) · OOP, descriptors, property, cached_property, attribute lookup · 8 min

Question: Explain how obj.attr is resolved in Python, and use it to explain why a validating Positive() field can't be bypassed through obj.__dict__, while a cached_property goes stale after the attribute it depends on changes.

Answer:

obj.attr calls type(obj).__getattribute__, which for normal classes does:

  1. Look up attr on the type (walking the MRO). If found and it's a data descriptor (defines __set__ or __delete__), call its __get__ and return. Data descriptors win over the instance dict.
  2. Otherwise, if attr is in the instance __dict__, return it.
  3. Otherwise, if the type attribute is a non-data descriptor (only __get__: functions, classmethod, staticmethod, cached_property), call its __get__.
  4. Otherwise return the plain class attribute; failing everything, call __getattr__ if defined, else raise AttributeError.

Consequences:

  • property and a custom Positive field are data descriptors, so assignment always goes through __set__ (validation) and reads through __get__, even if someone writes into obj.__dict__ directly.
  • Methods are non-data descriptors; that's how obj.method becomes a bound method, and why an instance attribute with the same name shadows a method.
  • functools.cached_property is a non-data descriptor: the first access computes the value and stores it in the instance __dict__ under the same name; later accesses hit step 2 and never call the descriptor again. That's what makes it fast, and also why it goes stale: nothing invalidates it when inputs change. You invalidate with del obj.attr. It also needs a writable instance __dict__ (no __slots__-only classes) and has no lock since 3.12.
  • __set_name__ lets the descriptor learn the attribute name it's assigned to, so one class handles many fields.

This is the machinery behind ORMs (SQLAlchemy columns), Pydantic and dataclass-like libraries; knowing it is what lets you debug them.

from functools import cached_property

class Positive:
    """Data descriptor: defines __get__ and __set__."""
    def __set_name__(self, owner, name):
        self.attr = "_" + name
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.attr)
    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.attr[1:]} must be positive, got {value}")
        setattr(obj, self.attr, value)

class Loan:
    principal = Positive()
    def __init__(self, principal, rate):
        self.principal = principal
        self.rate = rate
    @cached_property
    def emi_table(self):
        print("(computing emi_table)")
        return [round(self.principal * self.rate / 12)]

loan = Loan(500_000, 0.09)
print(loan.emi_table)
print(loan.emi_table)
print(sorted(vars(loan)))
loan.__dict__["principal"] = -1          # instance dict cannot shadow a data descriptor
print(loan.principal)
loan.principal = 600_000
print(loan.emi_table)                    # stale: served from the instance dict
del loan.emi_table
print(loan.emi_table)
try:
    loan.principal = 0
except ValueError as exc:
    print(exc)

Output:

(computing emi_table)
[3750]
[3750]
['_principal', 'emi_table', 'rate']
500000
[3750]
(computing emi_table)
[4500]
principal must be positive, got 0

Follow-ups:

  1. "How would you make the cached value invalidate automatically when principal changes?" Have the Positive.__set__ (or a property setter) delete the dependent cached attributes, e.g. obj.__dict__.pop("emi_table", None). Or don't cache: compute on demand, or make the object immutable so the cache can never go stale.
  2. "Why does Loan.principal (accessed on the class) return the descriptor itself?" __get__ is called with obj=None for class access; returning self in that case is the convention, which lets tools and documentation introspect the descriptor.
  3. "Where do __getattribute__ and __getattr__ fit?" __getattribute__ is the whole algorithm above and runs on every access; overriding it is rarely justified and slow. __getattr__ is only the fallback after normal lookup raises AttributeError.

Scoring: Strong: Gives the lookup order with data vs non-data descriptors and the instance dict, explains property/method/cached_property in those terms, including staleness and invalidation via del, and mentions __set_name__. Acceptable: Knows property and cached_property behaviour but not the precedence rules that explain it. Red flag: Thinks attributes are just looked up in __dict__, or that cached_property recomputes when inputs change.

SR-TH-OOP-06 · An AttributeError inside a property falls through to __getattr__

Hard & Tricky · Theory · Senior (5–8 years) · OOP, property, __getattr__, debugging, AttributeError · 6 min

Question: Order wraps a raw payload dict and has a __getattr__ that falls back to self._data[name] for convenience. It also has a computed total property. After a refactor, invoices show totals that don't match the line items, and there's no error anywhere. How could that happen?

Answer:

Trap: "If the property raises, you get its exception; __getattr__ is only for missing attributes."

__getattr__ is called whenever normal attribute lookup raises AttributeError, and Python can't tell "the attribute doesn't exist" from "the property's code raised AttributeError internally". So a bug inside the property (a typo like line.amout, or a None where an object was expected) raises AttributeError, lookup falls through to __getattr__("total"), and:

  • if the payload happens to contain a total key (here, a stale client-sent total), you silently get the wrong value: the incident in the question;
  • otherwise, __getattr__ raises its own AttributeError: 'Order' object has no attribute 'total', which sends you looking for a missing property that is clearly defined in the class, while the real error (amout) is hidden.

Fixes and standards:

  • Avoid broad __getattr__ fallbacks on classes that have properties; expose the payload explicitly (order.raw["total"]) or generate explicit fields.
  • Inside properties, don't let AttributeError escape: convert it (raise RuntimeError(...) from exc) or let type checkers and tests catch the typo before it ships.
  • If __getattr__ must exist, make it refuse names that are defined on the class (if name in type(self).__dict__ or check dir(type(self))), so property failures surface instead of being masked.
  • Tests for computed properties should use payloads that don't contain the same key, or this bug passes them too.

class Line:
    def __init__(self, amount):
        self.amount = amount

class Order:
    def __init__(self, data):
        self._data = data

    @property
    def total(self):
        return sum(line.amout for line in self._data["lines"])   # typo after a refactor

    def __getattr__(self, name):
        try:
            return self._data[name]                              # "convenience" fallback
        except KeyError:
            raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}") from None

o = Order({"lines": [Line(499), Line(1)], "total": 450})        # client sent a stale total
print(o.total)
try:
    Order({"lines": [Line(499)]}).total
except AttributeError as exc:
    print("AttributeError:", exc)

Output:

450
AttributeError: 'Order' object has no attribute 'total'

Follow-ups:

  1. "Why can't Python distinguish the two cases?" Both are signalled the same way: object.__getattribute__ (which runs the property) raises AttributeError, and the fallback protocol only checks the exception type. There's no separate "missing" signal.
  2. "Does the same masking happen with hasattr and getattr(obj, name, default)?" Yes. They treat any AttributeError as "absent", so a buggy property makes hasattr(order, "total") return False, or getattr return the default, silently.
  3. "Where do you typically meet this pattern in real code?" Proxy and wrapper classes (API response wrappers, lazy objects, ORM-like models), and Django/SQLAlchemy models with properties that touch relationships returning None.

Scoring: Strong: Explains that any AttributeError raised inside the property triggers __getattr__, shows both outcomes (wrong value, misleading error), and gives concrete fixes (no broad fallback, convert errors in properties, guard class-defined names, tests without the shadowing key). Acceptable: Spots the typo and knows __getattr__ is involved, but not why the property's error disappears. Red flag: Assumes the property's exception propagates normally, or blames the client payload alone.

SR-TH-SCO-01 · Module-level state in a service, and how to keep it testable

Easy · Theory · Senior (5–8 years) · module globals, import-time side effects, dependency injection, testing, configuration · 5 min

Question: A service module does settings = Settings.from_env(), db = create_engine(settings.db_url) and redis = Redis(...) at import time, and functions use these module globals directly. What problems does this cause, and what pattern would you standardise on?

Answer:

Module-level code runs once, at first import, in whatever context imports it first. Putting I/O and environment reads there causes:

  • Import-time side effects: importing the module for a unit test, a CLI --help, a migration script or a type checker plugin tries to read env vars and open connections. Tests fail without a database, and import errors hide the real cause.
  • Hidden dependencies: functions read db from the module scope, so you can only substitute it by monkeypatching the global, and only if you patch the name where it's looked up.
  • Frozen configuration: env vars set after import, or per-test overrides, are ignored.
  • Process-model issues: clients created before a fork are shared with the children (see SR-TH-SCO-05).

A pattern to standardise on:

  • Modules define functions and classes only. Constants are fine, but no I/O at import.
  • Create resources in an explicit composition root: an app factory, FastAPI lifespan, or main().
  • Pass dependencies in (constructor parameters, FastAPI Depends) instead of reading globals. Tests then pass fakes.
  • Where a lazy singleton is convenient, use an accessor such as @functools.cache def get_settings(): .... It's created on first use, and tests can call get_settings.cache_clear() or override the dependency.

The trade-off is a little more wiring code in exchange for predictable startup, isolated tests and explicit lifecycles.

Follow-ups:

  1. "Is it ever fine to create a client at module import?" For cheap, side-effect-free objects (a compiled regex, a logger, a constant mapping), yes. The line is I/O, environment reads, threads and connections; those belong to startup code with a clear lifecycle and shutdown.
  2. "How does FastAPI's lifespan help here?" It gives one place to create shared clients at startup and close them at shutdown, and it runs per worker process, after any fork. The objects are stored on app.state or provided via dependencies, so tests can start the app with overrides.

Scoring: Strong: Names import-time side effects, hidden dependencies, frozen config and fork issues; proposes a composition root with dependency injection plus a lazy cached accessor; mentions testability and lifecycle. Acceptable: Recognises that import-time connections hurt tests and suggests lazy initialisation. Red flag: Sees module globals as the normal way to share clients and plans to fix tests with mock.patch everywhere.

SR-TH-SCO-02 · `from flags import FLAGS` doesn't see a reload done with `global`

Easy & Tricky · Theory · Senior (5–8 years) · global, imports, name binding, configuration reload, feature flags · 4 min

Question: flags.py holds FLAGS = {...} and a reload() function that does global FLAGS; FLAGS = fetch_flags(). A background thread calls flags.reload() every minute. checkout.py does from flags import FLAGS. After a flag is turned on in the admin console, checkout never sees it, though other modules do. Why?

Answer:

Trap: "Imported names are live links to the other module's variable, so a reload updates them everywhere."

from flags import FLAGS runs once and binds a new name FLAGS in checkout's global namespace to whatever object flags.FLAGS refers to at that moment. It isn't an alias of the variable. reload() uses global FLAGS to rebind the name in flags's namespace to a new dict. checkout.FLAGS still points to the old dict. Modules that read flags.FLAGS look the attribute up on each access and get the new one.

The same binding rule explains several other surprises:

  • mock.patch("flags.FLAGS", ...) doesn't affect checkout either. Patch checkout.FLAGS, where the name is looked up.
  • importlib.reload(flags) doesn't update names that other modules imported with from flags import ....

Fixes, best first:

  • Expose an accessor, flags.get(name) or flags.current(), so nobody holds the object directly.
  • Have callers import flags and read flags.FLAGS at use time.
  • Mutate in place (FLAGS.clear(); FLAGS.update(new)) so every holder sees the change. This works, but readers can observe a half-updated dict mid-update. Swapping a new immutable snapshot behind an accessor is safer (see SR-TH-MUT-05).

import sys
import types

flags = types.ModuleType("flags")
exec(
    """
FLAGS = {"upi_autopay": False}

def reload():
    global FLAGS
    FLAGS = {"upi_autopay": True}
""",
    flags.__dict__,
)
sys.modules["flags"] = flags

from flags import FLAGS          # what checkout.py does
import flags as flag_module      # what a well-behaved module does

flag_module.reload()
print(FLAGS["upi_autopay"], flag_module.FLAGS["upi_autopay"])

Output:

False True

Follow-ups:

  1. "Would from flags import FLAGS work if reload() did FLAGS.update(new) instead?" Yes: checkout.FLAGS and flags.FLAGS refer to the same dict, so in-place mutation is visible to both. The cost is that a reader on another thread can see a partly updated dict, or get RuntimeError if it iterates during the update.
  2. "How would you enforce the accessor pattern across a large codebase?" Make the variable private (_FLAGS) and export only get_flag(). Add a lint rule (ruff's banned-imports settings, or an import-linter contract) that forbids importing the internal name, and document it in the module docstring.

Scoring: Strong: Explains that from … import binds a new name to the current object and global rebinds only in the defining module; connects it to mock.patch and importlib.reload; proposes an accessor with snapshot swap. Acceptable: Recognises that checkout holds the old object and fixes it by importing the module, without the wider picture. Red flag: Blames caching or threading, or suggests adding global FLAGS in checkout.py.

SR-TH-SCO-03 · How the compiler decides a name is local, and a local import that breaks a handler

Medium · Theory · Senior (5–8 years) · scoping, compile time, UnboundLocalError, imports, linters, performance · 5 min

Question: A webhook handler uses the module-level json, then someone adds import json inside an if data.get("debug"): branch for a debug dump. Now every request fails with UnboundLocalError, even ones without debug. Explain how Python decides whether a name is local, and how you'd catch this before production.

Answer:

Scope is decided at compile time, per function, from the whole body. If a name is bound anywhere in the function, it's local for the entire function, regardless of where the binding is or whether that line ever runs. Binding operations include more than =:

  • =, augmented assignment (+=), and := outside comprehensions
  • import x and from m import x
  • def and class
  • for x in, with … as x, except … as x
  • del x
  • capture patterns in match

Here import json inside the if makes json local to handle. The earlier json.loads(...) reads the local slot before anything is assigned to it, so it raises UnboundLocalError on every call. You can see the decision in handle.__code__.co_varnames, or with the symtable module.

Catching it:

  • Linters: pyflakes or ruff report "local variable referenced before assignment" (F823), and redefinition warnings flag shadowing imports.
  • Tests: any test that calls the handler fails immediately, so this is a coverage gap.
  • Review rule: put imports at the top of the module. Use function-level imports only deliberately (to break a cycle or defer a heavy import), and never with a name the function also uses from module scope.

Locals are also cheaper. They're array slots read by LOAD_FAST, while globals need a dict lookup. Since 3.11 the specialising interpreter caches global lookups, so the old "alias globals to locals" micro-optimisation rarely pays off now. Profile before doing it.

import json


def handle(payload: str) -> dict:
    data = json.loads(payload)
    if data.get("debug"):
        import json                     # "local import for the debug dump"
        print(json.dumps(data, indent=2))
    return data


print(handle.__code__.co_varnames)
handle('{"order": "A-101"}')

Output:

('payload', 'json', 'data')
UnboundLocalError: cannot access local variable 'json' where it is not associated with a value

Follow-ups:

  1. "Would the same problem happen with for json in rows: later in the function?" Yes. A for target is a binding, so json becomes local for the whole function. Any binding of the name anywhere in the function body has this effect.
  2. "Why does Python decide scope statically instead of looking names up dynamically at run time?" Static resolution lets the compiler use fast indexed local slots and closure cells instead of dict lookups for every name, and it makes a name's meaning consistent throughout a function. The cost is this class of error, which tooling catches easily.

Scoring: Strong: States compile-time, whole-function scope; lists binding forms beyond = (including import); explains the error on every request; names linting, tests and a review rule; aware of the LOAD_FAST/global cost nuance. Acceptable: Recognises the local import shadows the global and moves it to the top, with a partial explanation. Red flag: Thinks the error only happens when debug is true, or that Python resolves names at the moment each line executes.

SR-TH-SCO-04 · exec with separate globals and locals behaves like a class body

Medium & Tricky · Theory · Senior (5–8 years) · exec, scoping, namespaces, plugins, rules engines, security · 5 min

Question: A pricing rules engine stores small Python snippets in the database and runs each with exec(src, {}, {}) "to keep them isolated". A new rule defines RATE = 18 and def gst(amount): return amount * RATE / 100, then calls gst(100). It fails with NameError: name 'RATE' is not defined. Why, and what would you change?

Answer:

Trap: "exec runs the snippet like a small module, so its top-level names are visible to its functions."

When exec gets two different mappings, the code runs the way a class body does. Top-level assignments (RATE, gst) go into the locals mapping. But a function's global scope is always the globals mapping it was defined with, which here is the empty {}. So inside gst, RATE is looked up in locals (not there), then globals (not there), then builtins, and the lookup fails. Calling gst at the top level works only because the top level can see its own locals.

The fix for the scoping problem is to pass one dict: ns = {}; exec(src, ns). Top-level names then land in the dict that is also the functions' globals, exactly like a module.

The bigger issue for a senior reviewer is that exec isn't isolation. Passing empty dicts doesn't sandbox anything: __builtins__ is injected automatically, and even with it removed, code can reach object.__subclasses__() and from there file and process access. Rules from a database are effectively remote code execution if that table can be edited. Safer designs:

  • A data-only rule format (JSON or YAML with a fixed schema) interpreted by your code.
  • A restricted expression evaluator that parses with ast and allows only whitelisted nodes and names.
  • If arbitrary code is a real requirement, run it in a separate, resource-limited process or container with no credentials.

src = """
RATE = 18

def gst(amount):
    return amount * RATE / 100

print(gst(100))
"""

ns = {}
exec(src, ns)                 # one namespace: behaves like a module
exec(src, {}, {})             # two namespaces: behaves like a class body

Output:

18.0
NameError: name 'RATE' is not defined

Follow-ups:

  1. "Why is a comprehension inside a class body affected by the same rule?" A comprehension's body runs in its own nested scope, and nested scopes skip class-body (and two-mapping exec) locals when they resolve names. Only the outermost iterable is evaluated in the class scope itself.
  2. "Where else do you see exec or eval in real codebases, and what do you check in review?" In code generation (dataclasses, namedtuple and attrs build methods with exec), templating, and notebook tooling. The review question is always whether the source can contain untrusted input. Generated code built from trusted templates is fine; code built from user or database strings is not.

Scoring: Strong: Explains the class-body-like semantics of two mappings, fixes with a single namespace, and raises that exec is not a sandbox, with safer alternatives. Acceptable: Fixes it by passing one dict, but doesn't address the security problem. Red flag: Fixes it with global RATE inside gst, or believes empty dicts make exec safe for untrusted input.

SR-TH-SCO-05 · What "global" means once a service runs as several processes

Hard · Theory · Senior (5–8 years) · module globals, multiprocessing, fork, spawn, gunicorn, connection pools, per-process state · 8 min

Question: A Flask/FastAPI service runs under gunicorn with 8 workers and --preload. Module globals hold a DB connection pool, an in-memory rate limiter (requests_this_minute = defaultdict(int)) and a background thread that refreshes a cache. Users report intermittent DB protocol errors, rate limits that let through 8× the allowed traffic, and a cache that never refreshes. Explain each in terms of what "global" means here.

Answer:

"Global" in Python means module scope in one interpreter process. With multiple workers, each process has its own copy of every module global. How that copy came about depends on the start method.

fork (gunicorn's default, and --preload imports the app before forking). Children get a copy-on-write image of the parent's memory as it was at fork time:

  • The connection pool's sockets are inherited by all 8 children. Several processes talk on the same TCP connection to the database, and their messages interleave. That's the intermittent protocol error. Fix: create pools after fork (a post_fork hook, an app factory, FastAPI lifespan), or call engine.dispose() in the child.
  • Threads are not copied. Only the thread that called fork exists in the child, so the refresher thread lives only in the master. Workers never refresh. Locks that were held by other threads at fork time stay locked forever in the child, a classic deadlock. Start threads per worker after fork.

The rate limiter is a per-process dict. Each worker counts only its own requests, so the effective limit is 8× and it varies with load balancing. Shared limits need shared state: Redis INCR with expiry, a token bucket in Redis, or the API gateway.

spawn/forkserver (the default on macOS and Windows, and the default in multiprocessing on Linux from Python 3.14) start a fresh interpreter and re-import your module, so module-level code runs again in each child. Nothing is inherited implicitly, which is safer, but import-time side effects repeat and the if __name__ == "__main__": guard becomes mandatory.

The general rule for a team: module globals are fine for immutable, process-local things (constants, compiled regexes, a lazily created per-process client). Anything that must be shared across workers (counters, limits, locks, caches that must agree) goes in an external store. Anything holding OS resources (sockets, threads, files) is created after the process model is set up.

Follow-ups:

  1. "Why use --preload at all, given these risks?" It imports the app once in the master, so code and read-only data pages are shared copy-on-write, which saves memory and startup time, and import errors fail fast before workers start. You keep the benefit by preloading code and immutable data while deferring sockets, threads and pools to post_fork or startup hooks. Note that CPython's reference counting writes to objects, so copy-on-write sharing degrades over time; gc.freeze() before fork helps a little.
  2. "A teammate proposes multiprocessing.Manager().dict() for the rate limiter. Thoughts?" It works only within one machine and one process tree. Every operation is a round-trip to a manager process, and it doesn't survive a deploy or scale beyond one pod. For a service, use Redis or the gateway, which already handles horizontal scaling.

Scoring: Strong: Explains per-process globals, fork semantics (inherited sockets, missing threads, held locks), and spawn re-import; maps each symptom to its cause; proposes post-fork initialisation and external shared state. Acceptable: Knows each worker has its own globals and fixes the rate limiter with Redis, but is vague on the fork-specific socket and thread problems. Red flag: Thinks workers share module globals, or proposes a threading.Lock around the rate-limiter dict as the fix.

SR-TH-SCO-06 · Zero-argument super() and the hidden __class__ cell

Hard & Tricky · Theory · Senior (5–8 years) · super, closures, __class__ cell, MRO, monkeypatching, mixins · 6 min

Question: Two code-review findings: (1) a base repository uses super(type(self), self).save() "so it works for any subclass"; (2) a metrics library attaches a function that calls super().save() to existing classes at runtime (Repo.save = save_with_metrics). What goes wrong in each case, and what does that tell you about how super() finds its class?

Answer:

Trap: "super() without arguments means super(type(self), self). It's worked out from the instance at run time."

Zero-argument super() is lexically scoped. When the compiler sees super (or __class__) inside a function in a class body, it gives that function an implicit closure cell named __class__. type.__new__ fills the cell with the class being defined. At run time, super() means super(__class__, <first argument>). So it's tied to the class where the method was written, not to the instance's type.

  1. super(type(self), self) is wrong as soon as someone subclasses. For a CachedRepo(AuditedRepo) instance, type(self) is CachedRepo, so super(CachedRepo, self).save resolves to AuditedRepo.save, the very method that's running. It calls itself until RecursionError. It works in tests only because nobody subclassed yet.
  2. A function defined outside a class body has no __class__ cell, so super() raises RuntimeError: super(): __class__ cell not found when called, however you attach the function. Fix: use the explicit two-argument form with the intended class, or better, add behaviour via a mixin class or a decorator around the existing method instead of patching methods in.

Related: a metaclass whose __new__ builds the class without passing the __classcell__ entry through to type.__new__ breaks zero-argument super() in that class (a RuntimeError at class creation).

class Repo:
    def save(self):
        return "Repo.save"


class AuditedRepo(Repo):
    def save(self):
        return "audit+" + super(type(self), self).save()


class CachedRepo(AuditedRepo):
    pass


print(AuditedRepo().save())
try:
    CachedRepo().save()
except RecursionError:
    print("RecursionError")


def save_with_metrics(self):
    return "metrics+" + super().save()


class MeteredRepo(Repo):
    pass


MeteredRepo.save = save_with_metrics
try:
    MeteredRepo().save()
except RuntimeError as e:
    print("RuntimeError:", e)

Output:

audit+Repo.save
RecursionError
RuntimeError: super(): __class__ cell not found

Follow-ups:

  1. "In cooperative multiple inheritance, why must every save in the hierarchy call super().save()?" super() follows the MRO of the instance's class starting after __class__, so the next class may be a sibling mixin the author never heard of. If one class in the chain doesn't call super(), every class after it in the MRO is skipped silently. Methods should also accept and forward **kwargs so signatures stay compatible.
  2. "How can you see the __class__ cell?" AuditedRepo.save.__code__.co_freevars is ('__class__',) for a method that uses zero-argument super(), and AuditedRepo.save.__closure__[0].cell_contents is AuditedRepo. It's an ordinary closure cell created by the compiler.

Scoring: Strong: Explains the compiler-created __class__ cell, predicts the recursion and the RuntimeError, and recommends explicit two-argument super or mixins/decorators over monkeypatching. Acceptable: Knows super(type(self), self) is a known anti-pattern and why it recurses, but can't explain the zero-argument mechanism. Red flag: Believes super() is resolved from type(self), or recommends super(type(self), self) as the safe general form.

SR-TH-SRT-01 · Deterministic ordering for paginated APIs and reports

Easy · Theory · Senior (5–8 years) · sorting, stable sort, tie-breakers, keyset pagination, determinism, APIs · 4 min

Question: A partner reports that your /orders API sometimes returns the same order on two pages and skips another. The handler sorts by created_at and pages with offset/limit. What's going on, and what ordering rule would you set for the team?

Answer:

Many orders share the same created_at (batch imports, second-level precision), so the sort key is not unique. Rows with equal keys have no defined order in SQL, and across two queries (or after an insert, a vacuum, a replica switch) the database may return the ties in a different order. Page 1 and page 2 are separate queries, so a tied row can land on both pages or neither. Python's sorted is stable, but stability only preserves the order the rows arrived in, which is the nondeterministic DB order; it doesn't create a total order.

Team rule: every externally visible ordering is a total order. Append a unique tie-breaker, typically the primary key: ORDER BY created_at, id in SQL, key=lambda o: (o["created"], o["id"]) in Python. Then:

  • Use keyset (cursor) pagination: "rows after (created_at, id) of the last row I returned". It is stable under concurrent inserts, O(page) per request with an index on (created_at, id), and avoids the O(offset) cost of deep OFFSET pages.
  • Encode the cursor opaquely (base64 of the tuple), so clients can't depend on its shape.
  • Compare like with like: timestamps should be aware UTC values, not mixed-offset strings.

The same rule matters for reports and tests: a CSV whose row order changes between runs breaks diff-based reconciliation and golden-file tests even when the data is identical.

orders = [
    {"id": 7, "created": "2026-10-04 10:00"},
    {"id": 3, "created": "2026-10-04 10:00"},
    {"id": 9, "created": "2026-10-04 09:30"},
    {"id": 5, "created": "2026-10-04 10:00"},
]

# Deterministic total order: the unique id breaks ties on the timestamp.
ordered = sorted(orders, key=lambda o: (o["created"], o["id"]))
print([o["id"] for o in ordered])

# Keyset pagination: "give me rows after the last (created, id) I saw".
def page_after(rows, cursor, size):
    return [r for r in rows if (r["created"], r["id"]) > cursor][:size]

first = ordered[:2]
cursor = (first[-1]["created"], first[-1]["id"])
print([o["id"] for o in first], cursor, [o["id"] for o in page_after(ordered, cursor, 2)])

Output:

[9, 3, 5, 7]
[9, 3] ('2026-10-04 10:00', 3) [5, 7]

Follow-ups:

  1. "The client wants newest first. How does the keyset condition change?" ORDER BY created_at DESC, id DESC and WHERE (created_at, id) < (:ts, :id). Both columns must flip direction together; mixing DESC, ASC breaks the row-value comparison, and you'd need the expanded form created_at < :ts OR (created_at = :ts AND id > :id), with an index that matches.
  2. "Why not just keep offset pagination and add the tie-breaker?" The tie-breaker fixes the nondeterminism, but offsets still shift when rows are inserted or deleted between page requests (items move across page boundaries), and OFFSET 100000 makes the DB walk and discard 100,000 rows. Keyset fixes both.

Scoring: Strong: Identifies the non-unique sort key and per-query nondeterminism, explains why stability doesn't help, mandates a unique tie-breaker, and proposes keyset pagination with a matching index. Acceptable: Adds id as a tie-breaker but stays with offset pagination and can't explain the insert-shift problem. Red flag: Blames caching or the partner's client, or proposes sorting the full table in Python on every request.

SR-TH-SRT-02 · Sorting ISO-8601 strings with different UTC offsets

Easy & Tricky · Theory · Senior (5–8 years) · sorting, datetime, time zones, ISO 8601, event ordering, IST · 4 min

Question: An event-replay tool merges payment events from two systems and sorts them by their ISO-8601 timestamp strings, "because ISO strings sort chronologically". One system emits IST (+05:30), the other UTC (+00:00). Is the replay order right?

Answer:

Trap: "ISO-8601 strings always sort chronologically." Only when every string has the same offset and the same format. Lexicographic order compares characters, so "...T05:00:00+00:00" sorts before "...T10:00:00+05:30". But 10:00 IST is 04:30 UTC, which is earlier. The replay applies the refund request before the payment capture, and a state machine that rejects "refund before capture" fails, or worse, accepts it in the wrong order.

Fix at the boundary, not at sort time:

  • Parse to aware datetimes and normalise to UTC on ingestion (datetime.fromisoformat(s).astimezone(timezone.utc)); sort and compare datetime objects, which compare by instant.
  • If you must keep strings (log lines, object-store keys, partitions), mandate one canonical format: UTC, Z suffix, fixed precision. Then lexicographic order does match time order.
  • Reject naive timestamps at the boundary. Comparing a naive and an aware datetime raises TypeError (shown below), and treating naive as "local time" silently depends on the server's timezone.

Even with correct instants, two systems' clocks aren't perfectly in sync. For true causal order within an entity, use a per-entity sequence number or version from the source of truth, with time as a secondary key.

from datetime import datetime

events = [
    ("payment_captured", "2026-10-04T10:00:00+05:30"),   # 04:30 UTC
    ("refund_requested", "2026-10-04T05:00:00+00:00"),   # 05:00 UTC
]

by_string = sorted(events, key=lambda e: e[1])
by_instant = sorted(events, key=lambda e: datetime.fromisoformat(e[1]))
print([name for name, _ in by_string])
print([name for name, _ in by_instant])

try:
    sorted([datetime(2026, 10, 4, 10, 0), datetime.fromisoformat(events[0][1])])
except TypeError as e:
    print("TypeError:", e)

Output:

['refund_requested', 'payment_captured']
['payment_captured', 'refund_requested']
TypeError: can't compare offset-naive and offset-aware datetimes

Follow-ups:

  1. "All strings are UTC with a Z suffix, but some have milliseconds and some don't. Safe to sort as strings?" No. "10:00:00.5Z" vs "10:00:00Z" compares . with Z at the same position, and . (0x2E) sorts before Z (0x5A), so the later event sorts first. Fixed-width formatting (always .000) is part of the canonical format, or don't sort strings at all.
  2. "Where else does this bite in a data platform?" Partition paths like dt=2026-10-04 derived from local time vs UTC (an IST day spans two UTC dates), S3 key listings used as an ordering, and max(timestamp_str) used as an incremental-load watermark. A wrong watermark silently skips or re-loads data.

Scoring: Strong: Spots that the IST event is earlier and the string sort is wrong, normalises to aware UTC at ingestion, knows naive/aware comparison raises, and mentions canonical fixed-width formats and per-entity sequence numbers for causal order. Acceptable: Parses to datetime and sorts correctly, but doesn't discuss canonicalisation at the boundary or clock skew. Red flag: Agrees ISO strings always sort correctly, or "fixes" it by stripping the offset.

SR-TH-SRT-03 · Sorting data that doesn't fit in memory

Medium · Theory · Senior (5–8 years) · external sort, heapq.merge, memory, data pipelines, DuckDB, streaming · 6 min

Question: A nightly job must produce a 60 GB file of transaction records sorted by transaction ID; the worker has 4 GB of RAM. How would you do it, and when would you not do it in Python at all?

Answer:

Classic external merge sort:

  1. Run generation. Read the input in chunks that fit comfortably in memory (say 1 GB of records), sort each chunk with sorted(..., key=...), and spill it to a temporary file. You get k sorted runs.
  2. k-way merge. Open all runs and merge them lazily with heapq.merge(*runs, key=...). It keeps one head item per run in a heap, so memory is O(k) records plus file buffers, and time is O(n log k).
  3. Stream the merged output straight to the destination.

Details that separate a working job from a demo:

  • Keys must compare correctly after the round-trip to disk. Text loses types: "T10" < "T9" as strings. Write fixed-width keys or parse them back before comparing.
  • Fan-in limits. Thousands of runs mean thousands of open files and tiny read buffers; do a multi-pass merge (merge 100 runs at a time) or make runs larger.
  • Temp space and cleanup: you need about as much disk as the input; use tempfile.TemporaryDirectory and clean up on failure. For a resumable job, keep finished runs and record progress.
  • Stability: if equal IDs must keep input order, include a sequence number in the key.

When not to hand-roll it: if the data lives in a warehouse, ORDER BY there. Locally, DuckDB (COPY (SELECT * FROM 'in.parquet' ORDER BY txn_id) TO 'out.parquet') spills to disk automatically and is far faster than Python object-by-object; GNU sort with -S and -T is also an external sort. Also question the requirement: if consumers only need lookups or joins by ID, partitioning by a hash or range of the ID (each partition sorted independently) may be enough and parallelises better.

import heapq
import os
import tempfile


def write_sorted_runs(rows, run_size, tmpdir):
    """Sort fixed-size chunks in memory and spill each one to its own file."""
    paths, chunk = [], []
    for row in rows:
        chunk.append(row)
        if len(chunk) == run_size:
            paths.append(spill(sorted(chunk), tmpdir, len(paths)))
            chunk = []
    if chunk:
        paths.append(spill(sorted(chunk), tmpdir, len(paths)))
    return paths


def spill(chunk, tmpdir, n):
    path = os.path.join(tmpdir, f"run{n}.txt")
    with open(path, "w") as f:
        f.writelines(f"{line}\n" for line in chunk)
    return path


def read_run(path):
    with open(path) as f:
        for line in f:
            yield line.rstrip("\n")


txn_ids = ["T09", "T03", "T11", "T01", "T07", "T05", "T10", "T02", "T08"]
with tempfile.TemporaryDirectory() as tmp:
    runs = write_sorted_runs(txn_ids, run_size=4, tmpdir=tmp)
    print(len(runs), "runs")
    merged = heapq.merge(*(read_run(p) for p in runs))   # lazy k-way merge
    print(list(merged))

Output:

3 runs
['T01', 'T02', 'T03', 'T05', 'T07', 'T08', 'T09', 'T10', 'T11']

Follow-ups:

  1. "How would you parallelise this on an 8-core machine?" Run generation is embarrassingly parallel: sort chunks in a process pool (sorting Python objects is CPU-bound and GIL-limited). The merge is sequential per output file, but you can range-partition first (sample keys to pick 8 split points), sort each range independently and concatenate the outputs. That's what distributed engines do.
  2. "Why is heapq.merge better here than concatenating the runs and calling sorted again?" sorted needs everything in memory at once, which is what we're avoiding. heapq.merge is lazy, relies on each input already being sorted, and does O(log k) work per item.

Scoring: Strong: Describes run generation plus lazy k-way merge with heapq.merge, covers key serialisation, fan-in and temp-space issues, and knows when DuckDB/SQL/sort or partitioning is the better engineering choice. Acceptable: Knows external sort conceptually and uses heapq.merge, but misses the practical details. Red flag: Proposes a bigger machine or pandas.read_csv of the whole file, or sorts each chunk and concatenates the chunks without merging.

SR-TH-SRT-04 · dataclass(order=True) compares every field, and fails only on ties

Medium & Tricky · Theory · Senior (5–8 years) · sorting, dataclasses, order=True, heapq, priority queues, tie-breakers · 5 min

Question: A job scheduler stores @dataclass(order=True) class Job: priority: int; name: str; payload: dict in a sorted list (and in a heapq). It ran for weeks, then started failing with TypeError in production. Explain, and say how you'd define job ordering.

Answer:

Trap: "order=True sorts by the first field." It generates __lt__ and friends that compare tuples of all fields with compare=True, in definition order: (priority, name, payload). Most of the time priority or name differ and the comparison never reaches payload. The day two jobs have the same priority and the same name, Python compares the two dicts, and dicts don't support <: TypeError. A latent bug that only fires on a data-dependent tie is exactly how this passes tests and fails weeks later.

The same failure hits the common heapq.heappush(heap, (priority, item)) pattern when priorities tie and item isn't orderable.

How to define it:

  • Decide the ordering explicitly and make it a total order: priority, then a unique, monotonic sequence number (from itertools.count()), which also gives FIFO among equal priorities.
  • Exclude everything else from comparison with field(compare=False). Note compare=False also removes the field from __eq__, which is usually what you want for a scheduling envelope, but state it.
  • Or don't use order=True at all: sort with an explicit key=lambda j: (j.priority, j.seq) and keep the data class's equality about identity of the job. Ordering and equality are separate concerns.

Also a review point: with order=True, reordering fields changes behaviour silently. Adding a field in the "wrong" place changes sort order with no type error.

from dataclasses import dataclass, field


@dataclass(order=True)
class Job:
    priority: int
    name: str
    payload: dict


jobs = [Job(2, "reindex", {"shard": 4}), Job(1, "invoice", {"id": 9})]
print([j.name for j in sorted(jobs)])

jobs.append(Job(2, "reindex", {"shard": 1}))      # same priority AND name
try:
    sorted(jobs)
except TypeError as e:
    print("TypeError:", e)


@dataclass(order=True)
class SafeJob:
    priority: int
    seq: int                                       # unique tie-breaker
    name: str = field(compare=False)
    payload: dict = field(compare=False)


safe = [SafeJob(2, 0, "reindex", {"shard": 4}), SafeJob(1, 1, "invoice", {}),
        SafeJob(2, 2, "reindex", {"shard": 1})]
print([(j.name, j.seq) for j in sorted(safe)])

Output:

['invoice', 'reindex']
TypeError: '<' not supported between instances of 'dict' and 'dict'
[('invoice', 1), ('reindex', 0), ('reindex', 2)]

Follow-ups:

  1. "What test would have caught this before production?" A test that explicitly constructs ties on every prefix of the comparison key, or a property-based test (Hypothesis) generating jobs with small value ranges so ties are common. Tests built from realistic but unique data never exercise the tie path.
  2. "Why does heapq documentation recommend (priority, count, task) entries?" Exactly this: the count guarantees the comparison is decided before reaching task, keeps equal-priority tasks in insertion order, and lets you mark entries as removed without needing task to be comparable.

Scoring: Strong: Explains field-tuple comparison and the tie-only failure, connects it to the heapq tuple pattern, fixes it with a sequence tie-breaker and compare=False, and notes field order becoming part of behaviour. Acceptable: Finds the dict comparison and adds compare=False, but doesn't add a tie-breaker or explain why it surfaced late. Red flag: Believes order=True uses only the first field, or "fixes" it with a try/except around the sort.

SR-TH-SRT-05 · A 50-lakh-row sort that takes 40 seconds

Hard · Theory · Senior (5–8 years) · sorting, performance, profiling, key functions, NumPy, pandas, Timsort · 8 min

Question: A reporting job sorts 50 lakh (5 million) transaction dicts with sorted(rows, key=lambda r: (parse_date(r["date"]), -float(r["amount"]), r["merchant"].lower())), and profiling shows about 40 seconds in that line. Walk me through how you'd make it fast.

Answer:

First, attribute the time correctly. The sort does two kinds of work:

  • Key computation: once per element, so 5 million calls of the lambda, each parsing a date string, converting a float and lowercasing a string. Python sorts with decorate-sort-undecorate, so the key isn't recomputed per comparison. With string parsing, this is often most of the 40 s.
  • Comparisons: about n·log₂n ≈ 5M × 22 ≈ 11 crore comparisons of 3-tuples of Python objects. Tuple comparison is done in C but still calls rich comparison per element.

Then, in rough order of payoff:

  1. Don't sort if the consumer doesn't need a full sort. Top-N: heapq.nsmallest or np.argpartition. Grouped totals: a hash aggregation. The cheapest sort is the one you avoid.
  2. Push it down. If the rows came from a database or Parquet, ORDER BY there (or in DuckDB/Polars), where sorting is vectorised and multi-threaded.
  3. Precompute cheap keys at parse time. Parse dates into ints (epoch days) and amounts into integer paise once when you read the file, not inside the sort; casefold the merchant once. Simpler keys also compare faster: ints beat datetime objects and floats beat strings.
  4. Go columnar. With the data in NumPy/pandas, np.lexsort((merchant_codes, -amount_paise, day)) or df.sort_values([...], ascending=[True, False, True]) sorts typed arrays in C, often 10-50× faster than lists of dicts, and uses a fraction of the memory. Encode the string column as categorical codes if its order matters only as a tie-breaker.
  5. Exploit existing order. Timsort (powersort merge policy since 3.11) is O(n) on already-sorted runs. If the file arrives date-sorted, a stable sort by the secondary keys followed by a stable sort by date, or simply sorting within each day, can be much cheaper.

Measure each step (cProfile for the key function's share, time.perf_counter around the sort alone), and check peak memory: 5 million dicts are already several GB.

Follow-ups:

  1. "Is operator.itemgetter faster than a lambda as a key?" Slightly, because it runs in C without a Python frame per call, and itemgetter("a", "b") builds a tuple key directly. It's a micro-optimisation: worth it in a hot loop, irrelevant compared with removing string parsing from the key.
  2. "pandas sort_values on several columns with mixed ascending flags: is it stable?" Single-column sort_values defaults to quicksort (not stable); pass kind="stable" when you depend on the previous order. Multi-column sorts go through a lexicographic sort that is stable, but rather than relying on that detail, include an explicit unique tie-breaker column when the output order matters.

Scoring: Strong: Separates key-computation cost (n calls) from comparison cost (n log n), questions whether a full sort is needed, precomputes typed keys, moves to columnar or pushdown sorting, and mentions Timsort's run detection. Proposes measuring each change. Acceptable: Suggests precomputing keys and using pandas/NumPy, without quantifying where the time goes. Red flag: Proposes multiprocessing the sort or writing a custom quicksort in Python, or thinks the key function is called on every comparison.

SR-TH-SRT-06 · bisect's key= applies to the list, not to the value you search for

Hard & Tricky · Theory · Senior (5–8 years) · bisect, insort, key functions, binary search, sorted containers, API semantics · 6 min

Question: A fee engine keeps fee slabs as (lower_bound, rate) tuples sorted by lower bound and uses bisect_left(slabs, x, key=lambda s: s[0]) (3.10+) to find a slab, and insort(slabs, new_slab, key=...) to add one. A teammate writes the lookup as bisect_left(slabs, (2000, "2%"), key=...) "to be consistent with insort". What happens, and what's the cost model of this structure?

Answer:

Trap: "key= works like in sorted(): it's applied to everything, including the value you pass." For the search functions (bisect_left, bisect_right), key is applied to the list elements only. The needle x must already be a key value. Passing a whole tuple compares 2000 < (2000, "2%"), giving TypeError: '<' not supported between instances of 'int' and 'tuple'.

For the insertion functions (insort_left, insort_right), it's the other way round: key is applied to the new item too, so you pass a whole element. Passing 7000 makes it evaluate 7000[0]: TypeError: 'int' object is not subscriptable. The asymmetry is deliberate (searching by key is the common case; inserting needs a full record), but it surprises nearly everyone, and the error appears only on the code path that calls the "other" function.

Cost model, which matters more than the API detail:

  • bisect is O(log n) comparisons. insort is O(n) because inserting into a Python list shifts every later element (a fast memmove, but linear). Fine for a few hundred slabs that rarely change; quadratic overall for a stream of inserts into a large list.
  • key is called O(log n) times per search, not cached. For hot paths, keep a parallel list of plain keys (bounds = [s[0] for s in slabs]) and bisect that, which also works on older Pythons.
  • For many inserts and deletes with ordered queries, use sortedcontainers.SortedList/SortedDict (O(log n)-ish operations), a heap if you only need the minimum, or let the database's index do it.

from bisect import bisect_left, insort

slabs = [(0, "0%"), (500, "1%"), (2000, "2%"), (10000, "3%")]   # sorted by lower bound

# key= is applied to the list ELEMENTS, but the needle must already be a key value
i = bisect_left(slabs, 2000, key=lambda s: s[0])
print(i, slabs[i])

try:
    bisect_left(slabs, (2000, "2%"), key=lambda s: s[0])
except TypeError as e:
    print("TypeError:", e)

# insort is the opposite: it applies key to the new item too, so pass a whole element
insort(slabs, (5000, "2.5%"), key=lambda s: s[0])
print(slabs)
try:
    insort(slabs, 7000, key=lambda s: s[0])
except TypeError as e:
    print("TypeError:", e)

Output:

2 (2000, '2%')
TypeError: '<' not supported between instances of 'int' and 'tuple'
[(0, '0%'), (500, '1%'), (2000, '2%'), (5000, '2.5%'), (10000, '3%')]
TypeError: 'int' object is not subscriptable

Follow-ups:

  1. "To find the slab that applies to an amount of ₹2,000 exactly, should you use bisect_left or bisect_right?" Slabs are "lower bound inclusive", so you want the last slab whose bound is ≤ amount: bisect_right(bounds, amount) - 1. bisect_left(bounds, 2000) - 1 gives index 1, the ₹500 slab: correct for amounts between bounds, wrong exactly on a boundary. That off-by-one survives any test that doesn't use boundary amounts, so write 0, 499, 500, 1999 and 2000 as test cases.
  2. "Why does bisect silently return garbage on an unsorted list instead of raising?" Checking sortedness is O(n), which would defeat an O(log n) search. The sorted invariant is the caller's contract, so the structure that maintains it should own it: wrap the list in a small class that only allows insort and validates on construction.

Scoring: Strong: Explains the search-vs-insert key asymmetry with both errors, gives the O(log n) search / O(n) insert cost model, and proposes a parallel key list or SortedList for heavy updates. Acceptable: Knows the needle must be a key for bisect_left but is unaware of insort's opposite behaviour or its O(n) cost. Red flag: Expects both calls to work, or believes insort is O(log n) overall.

SR-TH-STR-01 · Formatting rupee amounts for Indian users

Easy · Theory · Senior (5–8 years) · strings, formatting, localisation, Decimal, Indian numbering · 4 min

Question: Product wants invoice PDFs and notification SMS to show amounts the Indian way, ₹12,34,567.50, but the code uses f"₹{amount:,.2f}" everywhere. What's wrong with that, and where should formatting live in the codebase?

Answer:

, in a format spec always groups in threes (1,234,567.50), which is the international style; Indian grouping is three digits, then twos (lakh, crore): 12,34,567.50. The format mini-language has no option for it.

Options, in the order I'd consider them:

  • A localisation library (Babel: format_currency(amount, "INR", locale="en_IN")) handles grouping, symbol placement and other locales if the product expands.
  • The locale module (locale.setlocale(LC_ALL, "en_IN.UTF-8") + locale.currency(...)) is a poor fit for services: the locale is process-global and not thread-safe to switch per request, and the locale must be installed in the container image.
  • A small, tested formatter of our own (as below) is fine if INR is the only currency.

Design points that matter more than the grouping itself:

  • Format only at the presentation edge (PDF, SMS, email templates). APIs, logs, CSV exports and the database carry machine values: integer paise or a decimal string like "1234567.50". Clients format for their own locale.
  • Format from Decimal or integer paise, never from float, and quantize with an explicit rounding mode first; :.2f on a float rounds the binary value, not the decimal one.
  • One shared function for the whole codebase, with tests for negatives, zero, small amounts, crores and rounding, so the PDF and the SMS never disagree.

from decimal import Decimal, ROUND_HALF_UP

def format_inr(amount: Decimal) -> str:
    """Indian digit grouping: last 3 digits, then groups of 2."""
    amount = amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    sign = "-" if amount < 0 else ""
    whole, frac = f"{abs(amount):.2f}".split(".")
    head, tail = whole[:-3], whole[-3:]
    groups = []
    while len(head) > 2:
        groups.insert(0, head[-2:])
        head = head[:-2]
    if head:
        groups.insert(0, head)
    return f"{sign}₹{','.join(groups + [tail])}.{frac}"

print(f"{1234567.5:,.2f}")
for v in ["1234567.5", "999", "100000", "-25000000.456"]:
    print(format_inr(Decimal(v)))

Output:

1,234,567.50
₹12,34,567.50
₹999.00
₹1,00,000.00
-₹2,50,00,000.46

Follow-ups:

  1. "An SMS template has a 160-character budget. Does the ₹ symbol affect it?" Yes. ₹ isn't in the GSM-7 alphabet, so the whole SMS switches to UCS-2 encoding with a 70-character limit per segment. Many teams write "Rs." in SMS for that reason. Character budgets depend on the encoding, not on len().
  2. "Why not store the formatted string in the database for the invoice?" You'd lose the numeric value for sums, reconciliation and other locales. Store the number (and currency); store the rendered PDF if you need an exact legal copy of what was sent.
  3. "How do you test a formatter like this beyond a few examples?" Property-based tests (Hypothesis): removing commas and the symbol from the output must give back the quantized input, and the digit groups must be 2s followed by a final 3.

Scoring: Strong: Knows , groups in threes, picks Babel or a tested shared formatter over process-global locale, formats only at the presentation edge from Decimal/paise with explicit rounding. Acceptable: Writes a correct lakh/crore formatter but doesn't address where formatting belongs or float rounding. Red flag: Stores formatted strings as the source of truth, or formats from floats with round().

SR-TH-STR-02 · Truncating UTF-8 bytes to fit a column limit

Easy & Tricky · Theory · Senior (5–8 years) · strings, bytes, UTF-8, truncation, Unicode, databases · 5 min

Question: A downstream system stores customer names in a 10-byte field. To fit, the integration does name.encode("utf-8")[:10].decode("utf-8"). It works for "Priya Sharma". What happens for प्रिया शर्मा, and how should truncation be done?

Answer:

Trap: "Slicing the encoded bytes and decoding back gives the first 10 bytes' worth of characters."

UTF-8 is variable-width: ASCII is 1 byte per code point, Devanagari 3 bytes, emoji 4. प्रिया शर्मा is 12 code points but 34 bytes. Slicing at byte 10 cuts in the middle of a code point, so .decode("utf-8") raises UnicodeDecodeError: unexpected end of data, and only for non-ASCII names, so it passed every English test case.

Even "fixed" versions have layers of correctness:

  1. Code-point safe: data[:10].decode("utf-8", errors="ignore") drops the partial trailing bytes. No exception, and the result fits.
  2. Grapheme safe: a visible character can be several code points: प्रि is प + ् + र + ि. Cutting between them leaves प्र, a different-looking syllable; the same happens with emoji sequences and combining accents. For user-visible text, truncate on grapheme cluster boundaries (the third-party regex module's \X, or a grapheme library) and then check the byte length.
  3. Know which limit you have: a database VARCHAR(n) in PostgreSQL counts characters, MySQL utf8mb4 columns count characters but have byte-based index limits, many partner fixed-width files and protocols count bytes, and SMS counts GSM-7 or UCS-2 units. Write the unit into the field's name or contract.

And ask whether truncation is right at all: for names, rejecting or flagging is often better than silently storing a mangled one.

name = "प्रिया शर्मा"
data = name.encode("utf-8")
print(len(name), "code points,", len(data), "bytes")
cut = data[:10]
try:
    cut.decode("utf-8")
except UnicodeDecodeError as exc:
    print("UnicodeDecodeError:", exc.reason, "at byte", exc.start)
safe = cut.decode("utf-8", errors="ignore")
print(repr(safe), len(safe.encode("utf-8")), "bytes")

Output:

12 code points, 34 bytes
UnicodeDecodeError: unexpected end of data at byte 9
'प्र' 9 bytes

Follow-ups:

  1. "Why does errors="ignore" produce प्र rather than प्रि?" The 10th byte is the first byte of the vowel sign ि (3 bytes); ignore drops that incomplete sequence, leaving प, the virama and र, which render as the conjunct प्र, a visibly different syllable from the original प्रि.
  2. "How would you write a byte-limited truncation that respects graphemes?" Iterate over grapheme clusters, append each while the running UTF-8 byte length stays within the limit, and stop at the first one that would exceed it. That's O(n) and never splits a code point or a cluster.
  3. "Where else does byte-vs-character length bite?" HTTP header and cookie size limits, Kafka message size, S3 object key limits (1024 bytes of UTF-8), and filename limits (255 bytes on most Linux filesystems).

Scoring: Strong: Explains variable-width UTF-8 and the mid-code-point cut, fixes with errors="ignore" but goes on to grapheme clusters, and distinguishes byte, code-point and character limits per system. Acceptable: Spots the decode error and fixes it with errors="ignore" without considering grapheme clusters. Red flag: Suggests errors="replace" and storing the �, or truncating with name[:10] without checking the byte length.

SR-TH-STR-03 · Regex denial of service on user input

Medium · Theory · Senior (5–8 years) · strings, regex, ReDoS, security, performance · 6 min

Question: A merchant onboarding API validates the "business name" field with re.fullmatch(r"([A-Za-z0-9]+\s?)*", name). One request pinned a worker at 100% CPU for minutes and the endpoint's p99 latency exploded. Explain what happened and how you'd prevent it across the codebase.

Answer:

This is catastrophic backtracking (ReDoS). Python's re is a backtracking engine. In ([A-Za-z0-9]+\s?)* the inner + and the outer * can split a run of letters in exponentially many ways (\s? is optional, so "abc" can be one group, or "a"+"bc", or "a"+"b"+"c"...). On a matching input that's fine; on a long almost-matching input like "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!", the final ! makes the match fail, and the engine tries every split before giving up: roughly 2ⁿ steps. About 25 characters already takes around a second, and each extra character doubles it; the regex runs in C holding the GIL, so in a threaded server the whole process stalls, not just one request.

Fixes for this pattern:

  • Remove the ambiguity: [A-Za-z0-9]+(?:\s[A-Za-z0-9]+)* matches the same language with only one way to split, so it fails in linear time.
  • Atomic groups / possessive quantifiers (Python 3.11+): (?>...) and *+, ++ stop the engine from backtracking into a group once it has matched.
  • Bound the input first: enforce a maximum length (e.g. 100 characters) before running any regex; most validation should start with this anyway.

Across the codebase:

  • Review rule: flag nested quantifiers ((a+)*, (a|aa)*, (.*,)*) and alternations with overlapping branches on any regex that sees external input. Linters and scanners (e.g. Semgrep rules, regexploit) can find candidates.
  • For user-supplied patterns (search filters), never run them in-process with re; use a linear-time engine such as google-re2 (Python bindings), or run matching in a subprocess with a timeout. re has no timeout parameter.
  • Prefer simple string methods or a parser when the "regex" is really structured validation (emails, URLs, PAN/GSTIN formats with fixed shapes).
  • Load-test validators with adversarial inputs, not just valid ones.

Follow-ups:

  1. "Why doesn't a request timeout in the web framework save you?" The timeout can't interrupt C code running inside re: a thread can't be killed, and asyncio timeouts only act at await points. The worker stays busy until the regex finishes; only killing the process (gunicorn's worker timeout) recovers it.
  2. "Write a linear-time regex for a GSTIN-shaped string." A fixed-shape pattern has no ambiguity: \d{2}[A-Z]{5}\d{4}[A-Z][1-9A-Z]Z[0-9A-Z] with re.fullmatch. Fixed-length tokens can't backtrack exponentially.
  3. "Is re.compile caching related to this problem?" No. Compilation cost is paid once; ReDoS is about matching time on specific inputs, which caching doesn't change.

Scoring: Strong: Explains backtracking with ambiguous nested quantifiers and exponential failure on near-matches, notes the GIL/process-wide impact, rewrites the pattern unambiguously, mentions atomic/possessive (3.11+), input length bounds, and RE2 or sandboxing for user patterns. Acceptable: Recognises ReDoS and suggests limiting length, but can't rewrite the pattern or explain why it explodes. Red flag: Blames the database or network, or proposes caching the compiled regex as the fix.

SR-TH-STR-04 · splitlines() splits on more than newlines

Medium & Tricky · Theory · Senior (5–8 years) · strings, splitlines, Unicode, data contracts, record counts · 5 min

Question: A reconciliation check compares the record count of a partner's CSV (computed by the partner with wc -l) with your loader's count, which does len(text.splitlines()) - 1. The counts disagree on some days, always with your count higher. What would you look for?

Answer:

Trap: "splitlines() is split("\n") that also handles \r\n and drops the trailing empty string."

str.splitlines() splits on every Unicode line boundary, not just \n, \r and \r\n: also \x0b (vertical tab), \x0c (form feed), \x1c–\x1e (file/group/record separators), \x85 (NEL), \u2028 (line separator) and \u2029 (paragraph separator). Free-text fields (addresses, remarks copied from Word or a web form) occasionally contain these. wc -l counts only \n bytes, so on those days your count is higher and records get split in the middle of a field.

Other ways the counts drift:

  • Quoted newlines in CSV: a remark containing \n inside quotes is one record but two physical lines. Count records with the csv module (opened with newline=""), never by lines.
  • Trailing newline or not: wc -l doesn't count a last line without \n; splitlines() does.
  • bytes.splitlines() only splits on \n, \r, \r\n, so the same data gives different counts as bytes vs as text.

Fixes:

  • Parse records with the csv module (or pandas/pyarrow with explicit options) and count records, not lines.
  • Put the record count in the data contract: a trailer row or a manifest file with count and checksum, so reconciliation compares like with like.
  • If you must split text into lines, use split("\n") or file iteration (which, in text mode with default newline, splits only on \n, \r, \r\n), and decide explicitly what to do with stray control characters (strip or reject at ingestion).

import io

raw = "id,remark\n1,ok\n2,fragile\x0cbox\n3,see\u2028note\n"
print(len(raw.split("\n")) - 1, "lines by split('\\n')")
print(len(raw.splitlines()), "lines by splitlines()")
print(raw.splitlines())
print(len(io.StringIO(raw).readlines()), "lines via file iteration")
print(len(raw.encode("utf-8").splitlines()), "lines via bytes.splitlines()")

Output:

4 lines by split('\n')
6 lines by splitlines()
['id,remark', '1,ok', '2,fragile', 'box', '3,see', 'note']
4 lines via file iteration
4 lines via bytes.splitlines()

Follow-ups:

  1. "A JSON Lines file written by a Python producer is split by a Java consumer on \n. Can \u2028 cause trouble there?" Not from json.dumps with defaults, which escapes non-ASCII including \u2028. It does if the producer uses ensure_ascii=False and the consumer, or any tool in between, treats U+2028 as a line break. Agree on the delimiter and escaping in the contract.
  2. "How would you clean such control characters at ingestion?" Define an allow-list for free-text fields (printable characters plus space), replace or strip others (e.g. via a translation table built for categories Cc/Zl/Zp except tab), record how many were changed, and keep the raw file for audit.
  3. "Why open CSV files with newline=""?" So the csv module sees the raw line endings and can correctly handle newlines inside quoted fields; otherwise text-mode translation can corrupt them, and on Windows you get blank rows when writing.

Scoring: Strong: Knows splitlines splits on Unicode line boundaries (names several), explains the count drift vs wc -l, and fixes by counting CSV records and adding counts to the data contract; mentions quoted newlines. Acceptable: Suspects embedded newlines in quoted fields but doesn't know splitlines' extra separators. Red flag: Treats line counts as record counts, or "fixes" it by subtracting a fudge factor.

SR-TH-STR-05 · How CPython stores str, and why one emoji quadruples memory

Hard · Theory · Senior (5–8 years) · strings, memory, PEP 393, Unicode, performance · 7 min

Question: A log-processing service keeps about 2 GB of log lines in memory as Python strings. After a release that adds a 🙂 to one status message, memory roughly doubles and some pods get OOM-killed. How can one character do that?

Answer:

Since Python 3.3 (PEP 393, "flexible string representation"), CPython stores each str in the narrowest fixed width that fits its widest code point:

  • 1 byte per character if every code point is ≤ U+00FF (ASCII and Latin-1);
  • 2 bytes per character if the widest is in the Basic Multilingual Plane (≤ U+FFFF), e.g. ₹, Devanagari;
  • 4 bytes per character if any code point is above U+FFFF, e.g. most emoji.

Width is per string, chosen when it's created, so a single emoji in a 1,000-character line makes the whole line 4 bytes per character: ~4 KB instead of ~1 KB. If log lines are built by concatenating with that status message, or a big chunk is read as one string and later sliced, every affected string grows. There's also a per-object header (about 40–56 bytes), which dominates for short strings and is why millions of small strings cost far more than their text.

Why it's designed this way: indexing and slicing stay O(1) (fixed width), and pure-ASCII text, the common case, is as compact as bytes. The trade-off is these step changes.

What to do in a memory-sensitive pipeline:

  • Don't hold raw text in memory as millions of str objects: stream (process line by line), or keep bytes (UTF-8 is 1 byte for ASCII and 4 only for the emoji itself) and decode on demand.
  • Use columnar storage for large text data: pyarrow / pandas with string[pyarrow] dtype stores UTF-8 in contiguous buffers, avoiding per-object headers and width inflation.
  • Measure with sys.getsizeof on representative samples and tracemalloc in staging, rather than estimating from file sizes.

import sys

line = "a" * 1000
print(sys.getsizeof(line))                 # 1 byte per char
print(sys.getsizeof(line[:-1] + "é"))      # Latin-1 range: still 1 byte per char
print(sys.getsizeof(line[:-1] + "₹"))      # BMP: 2 bytes per char
print(sys.getsizeof(line[:-1] + "🙂"))     # outside the BMP: 4 bytes per char

doc = "x" * 1_000_000
print(sys.getsizeof(doc) // 1000, "KB ->", sys.getsizeof(doc + "🙂") // 1000, "KB")
print(len(line.encode("utf-8")), len((line[:-1] + "🙂").encode("utf-8")))

Output:

1041
1057
2058
4060
1000 KB -> 4000 KB
1000 1003

Follow-ups:

  1. "Why is the Latin-1 string 16 bytes bigger than the pure ASCII one of the same length?" ASCII-only strings use the compact ASCII layout with a smaller header; any non-ASCII string uses a larger header that also has room for cached UTF-8 data. Both still store one byte per character.
  2. "Does len() or indexing get slower for 4-byte strings?" No, both are O(1) for every width. Costs that do scale are memory, copying (slicing, concatenation) and encoding to UTF-8 for I/O.
  3. "Would sys.intern help for repeated log levels or service names?" For a small set of values repeated millions of times, yes: interning (or a dict-based dedup) makes all occurrences share one object. pandas category dtype or Arrow dictionary encoding achieve the same for columns.

Scoring: Strong: Explains PEP 393's per-string 1/2/4-byte width chosen by the widest code point, quantifies the effect, mentions per-object overhead and the O(1)-indexing rationale, and proposes streaming, bytes, or Arrow-backed strings. Acceptable: Knows Python strings can use different widths but can't explain why one character affects the whole string. Red flag: Assumes Python strings are stored as UTF-8 internally, or blames the logging library.

SR-TH-STR-06 · Filenames that aren't valid UTF-8 and surrogateescape

Hard & Tricky · Theory · Senior (5–8 years) · strings, bytes, filenames, surrogateescape, os.fsdecode, encoding errors · 6 min

Question: An ingestion job lists a vendor's SFTP drop folder with os.listdir(), then writes each filename into a JSON manifest and a Postgres audit table. It has run for a year. One night it crashes with UnicodeEncodeError: ... surrogates not allowed, on a line that only encodes a filename. How can a str that came from the OS fail to encode as UTF-8?

Answer:

Trap: "A str returned by the OS is valid text, so it can always be encoded to UTF-8."

On Linux, filenames are arbitrary bytes, not text. A vendor system using Latin-1 or Windows-1252 can create invoice_\xff.csv, which isn't valid UTF-8. So that os.listdir(), sys.argv and os.environ never fail, Python decodes OS data with the surrogateescape error handler: each undecodable byte 0xNN becomes a lone surrogate code point U+DCNN. The str round-trips perfectly back to the original bytes via os.fsencode() (so open(name) works), but it is not valid Unicode: encoding it with plain UTF-8 (strict) raises UnicodeEncodeError: surrogates not allowed. That happens wherever the name crosses into a system that needs real text: the DB driver, json.dumps(..., ensure_ascii=False), an HTTP body, a log handler with a strict encoding.

It got past json.dumps with defaults because ensure_ascii=True writes "\udcff", an escape many other JSON parsers reject or replace, so the problem just moves downstream.

How to handle it:

  • Decide policy at the boundary: reject/quarantine such files with an alert, or represent the name safely: keep the raw bytes (os.fsencode(name)) and store a display version decoded with errors="replace" or backslashreplace (invoice_\xff.csv).
  • Use os.listdir(b"/path") (bytes in, bytes out) in code that must handle any filename faithfully, and decode deliberately.
  • When ingesting from a known partner, agree on the filename encoding in the data contract, and add a test case with a non-UTF-8 name.

import json
import os

raw_name = b"invoice_\xff.csv"            # from a vendor's SFTP: not valid UTF-8
name = os.fsdecode(raw_name)               # what os.listdir() would hand you on Linux
print(repr(name))
print(os.fsencode(name) == raw_name)       # round-trips back to the exact bytes
print(json.dumps({"file": name}))          # ASCII-escaped, so this "works"
print(name.encode("utf-8", errors="backslashreplace"))
name.encode("utf-8")

Output:

'invoice_\udcff.csv'
True
{"file": "invoice_\udcff.csv"}
b'invoice_\\udcff.csv'
UnicodeEncodeError: 'utf-8' codec can't encode character '\udcff' in position 8: surrogates not allowed

Follow-ups:

  1. "How do you get back the human-friendly invoice_\xff.csv display form rather than \udcff?" Encode back to the original bytes and decode with a lossy handler: os.fsencode(name).decode("utf-8", errors="backslashreplace") gives invoice_\xff.csv; errors="replace" gives invoice_�.csv.
  2. "Where else does Python use surrogateescape silently?" sys.argv, os.environ, and, under the C/POSIX locale or UTF-8 mode, sys.stdin/sys.stdout, so data piped through a script can carry surrogates into places that later encode strictly.
  3. "Why is this mostly a Linux problem and not a Windows one?" Windows filenames are UTF-16 at the OS level; Python uses the wide-character APIs, so names are always decodable (apart from rare unpaired surrogates). macOS filesystems require filenames to be valid UTF-8.

Scoring: Strong: Explains that POSIX filenames are bytes, the surrogateescape decoding into lone surrogates, why encoding strictly fails later, the misleading JSON escape, and a deliberate policy (reject, or keep bytes plus a lossy display form). Acceptable: Knows filenames can be non-UTF-8 and suggests errors="replace", without understanding surrogateescape or round-tripping. Red flag: Suggests errors="ignore" everywhere, or assumes the vendor file is corrupt and nothing in the code needs to change.

SR-TH-TST-01 · Deciding the test portfolio for a service you own

Easy · Theory · Senior (5–8 years) · testing strategy, test pyramid, integration tests, contract tests, CI · 6 min

Question: You've taken ownership of a FastAPI order service (Postgres, Redis, calls a payments provider and publishes events). It has 40 unit tests that mock everything and nothing else. How do you decide what tests it should have, and at which level?

Answer:

Start from risk, not from a pyramid quota: where do bugs actually hurt, and which test is the cheapest one that would catch them?

Level What it proves here Typical tools
Unit Pure domain logic: pricing, GST and discount rules, state transitions of an order. Fast, no I/O, many cases (parametrize, property-based). pytest, Hypothesis
Integration (real dependencies) SQL and migrations against real Postgres, Redis behaviour (TTLs, SET NX), transaction boundaries, the repository layer. Mocks can't tell you a query is wrong. testcontainers or a CI service container, one DB per test session, a transaction rolled back per test
API / component The HTTP contract: status codes, validation errors, auth, idempotency keys, serialisation. App wired for real, external providers faked at the network edge. TestClient/httpx.AsyncClient, dependency_overrides, respx
Contract Our assumptions about the payments API and the event schema we publish, which consumers depend on. consumer-driven contracts (Pact) or schema checks in CI (JSON Schema / Avro compatibility)
End-to-end / smoke A handful of critical journeys in a deployed environment: place order → pay → event emitted. Slow and flaky-prone, so few. run after deploy, gate the rollout

How I'd prioritise the gap here:

  1. Add a real-DB integration layer first. 40 mock-heavy tests mostly verify that the code calls the mocks the way the code calls the mocks; the riskiest bugs (SQL, transactions, migrations) are invisible to them.
  2. API tests for each endpoint's contract, especially error paths and idempotency.
  3. Contract tests at the two boundaries we don't control (payments, event consumers).
  4. Keep unit tests for genuinely complex logic; delete ones that only restate the implementation.

Standards I'd set for the team: the PR suite runs in under ~10 minutes, flaky tests are quarantined with an owner, every bug fix lands with a test that fails without it, and test code is reviewed like production code.

Follow-ups:

  1. "The integration suite takes 25 minutes. Developers start skipping it. What do you do?" Measure where the time goes (pytest --durations=20). Usual wins: one container per session instead of per test, a rolled-back transaction per test instead of truncating tables, run in parallel with pytest-xdist (one database per worker), and move slow end-to-end flows to a post-merge or nightly stage. Keep a fast required subset on PRs and the full set before deploy.
  2. "How do you know the test suite is actually good, beyond coverage?" Escaped-defect rate (how many production bugs a test should have caught), mutation testing on core modules, how often a red build points straight at the cause, and flake rate. Coverage only tells you what was executed, not what was checked.

Scoring: Strong: Chooses levels by risk; puts SQL, transactions and migrations in real-DB integration tests; separates API contract tests and consumer/provider contracts; sets concrete team standards (time budget, flake policy, regression test per bug). Acceptable: Describes a sensible pyramid and says to add integration tests, but stays generic about what belongs at each level. Red flag: "More unit tests with more mocks", or "the QA team covers end-to-end", or treats a coverage percentage as the goal.

SR-TH-TST-02 · 100% coverage and a surviving boundary bug

Easy & Tricky · Theory · Senior (5–8 years) · coverage, branch coverage, mutation testing, boundary values, test quality · 5 min

Question: A PR adds a GST-slab function with tests that reach 100% line and branch coverage. The author argues it's therefore fully tested. As the reviewer, do you agree?

Answer:

Trap: "Yes, every line and every branch runs, so every behaviour is checked."

Coverage measures what executed, not what was asserted, and not which inputs were tried. Two tests (₹500 and ₹5,000) take both branches of if amount <= 1000, yet neither checks the boundary, so changing <= to < (a classic off-by-one) still passes. The snippet runs both suites against the original and a mutant: the weak suite can't tell them apart; adding ₹1,000 and ₹1,001 kills the mutant.

What I'd ask for in review:

  • Boundary values from the specification: exactly ₹1,000, one paisa or rupee either side, zero, negative, and the largest expected amount.
  • Assertions that check outcomes, not just "didn't raise". A test with no meaningful assert still earns full coverage.
  • Mutation testing (mutmut, cosmic-ray) on core business logic: it flips operators and constants and reports mutants your tests don't catch. That measures the suite's power directly. It's slow, so run it on critical modules or nightly, not on every PR.

Coverage is still useful as a negative signal: uncovered code is definitely untested. A sensible standard is "no drop in coverage on changed lines" rather than a global number to hit, because targets invite assertion-free tests.

def gst_slab(amount: int) -> int:
    """GST rate in percent: 5% up to ₹1,000, 12% above."""
    if amount <= 1000:
        return 5
    return 12

def mutant(amount: int) -> int:          # `<=` mutated to `<`
    if amount < 1000:
        return 5
    return 12

def weak_suite(f):                       # 100% line and branch coverage
    return f(500) == 5 and f(5000) == 12

def boundary_suite(f):
    return weak_suite(f) and f(1000) == 5 and f(1001) == 12

for name, suite in [("weak", weak_suite), ("boundary", boundary_suite)]:
    print(f"{name:8} original={suite(gst_slab)} mutant={suite(mutant)}")

Output:

weak     original=True mutant=True
boundary original=True mutant=False

Follow-ups:

  1. "Line coverage vs branch coverage: what does branch coverage add, and is it enough?" Branch coverage (coverage run --branch) also requires each condition to go both ways, so an if without an else that's always true gets flagged. It still doesn't check boundaries, compound conditions (a and b has more cases than two branches), or that assertions are meaningful.
  2. "Mutation testing on the whole monorepo takes 9 hours. How would you use it?" Scope it: run on changed files in a nightly job, or on the few modules where a silent bug costs money (pricing, ledger, permissions). Treat surviving mutants as review prompts, not a hard gate, since some are equivalent mutants that no test could kill.

Scoring: Strong: Separates executed from asserted; names the boundary mutant; brings up mutation testing and its cost; proposes changed-line coverage as a floor rather than a target. Acceptable: Says coverage doesn't prove correctness and asks for boundary tests, but doesn't know a way to measure test strength. Red flag: Accepts 100% coverage as proof, or sets a coverage target as the main quality standard for the team.

SR-TH-TST-03 · Running down flaky tests in a shared CI suite

Medium · Theory · Senior (5–8 years) · flaky tests, test isolation, CI, pytest, test ordering, quarantine · 7 min

Question: The monorepo's 3,000-test suite fails on about one run in five, always on different tests, and people have started clicking "re-run" until it's green. You've been asked to fix it. How do you approach it?

Answer:

Treat it as a production incident for developer trust: measure, classify, fix the causes, and set a policy so it doesn't come back.

1. Get data, not anecdotes. Record every test result from CI (JUnit XML into a table). A test that both passed and failed on the same commit is flaky by definition. Rank by failure frequency; usually a handful of tests or one shared fixture cause most of the noise.

2. Reproduce by cause. The common causes, and how to expose each:

Cause How to expose it Fix
Order dependence / leaked state (module globals, singletons, lru_cache, env vars, a DB row left behind) Shuffle the order with pytest-randomly and replay a failing order with --randomly-seed=N; run the test alone vs after its neighbours Function-scoped fixtures, transaction rollback per test, monkeypatch instead of manual env changes, clear caches in a fixture
Time (now(), midnight UTC vs IST, DST, slow CI) Run with a frozen clock at awkward times (23:59 IST, month end) Inject a clock; time-machine for legacy code
Concurrency and async (sleeps as synchronisation, unawaited tasks) Run under load: pytest -n 8, --count=200 with pytest-repeat Wait on events or poll with a deadline, never sleep(0.5) and hope
Parallel workers sharing resources (same DB, port, temp file) Fails only with -n Per-worker DB via worker_id, tmp_path, port 0
Real network / external services Fails when offline Fake at the boundary (respx), containers for owned dependencies
Unordered results (set order, SELECT without ORDER BY) Different PYTHONHASHSEED, different DB plan Assert on sorted data or sets

3. Policy. Quarantine a known-flaky test (a flaky marker that runs but doesn't block) with an owner and a deadline; an untracked quarantine is just deletion. Disallow blanket auto-retries: a rerun plugin limited to quarantined tests is acceptable; retrying everything hides real race conditions that will also happen in production. Track the flake rate as a team metric.

Often the most valuable finding is that a "flaky test" is a real bug: a race in the code under test that also happens in production, just rarely.

Follow-ups:

  1. "A test fails only when run after test_reports.py. How do you find the leaking state quickly?" Bisect the preceding tests: run test_reports.py plus halves of what ran before the victim until one test pair reproduces (pytest-randomly seeds make the order reproducible; there are also bisect plugins). Then diff global state before and after the polluter: env vars, module-level caches, patched attributes left in place, rows in the DB, files in the CWD.
  2. "Should you add @pytest.mark.flaky(reruns=3) to the worst offenders to unblock the team?" As a short, tracked stopgap with a ticket and owner, yes. As a permanent fix, no: it hides real races, slows the suite, and normalises flakiness. The rerun count also lets a genuine 30% failure through most of the time.

Scoring: Strong: Starts with data (same-commit pass and fail), classifies causes with a concrete way to expose each, fixes isolation at the fixture level, and sets a quarantine-with-owner policy; recognises some flakes are real production races. Acceptable: Knows the usual causes (shared state, time, network, ordering) and the fixes, but has no plan for measurement or policy. Red flag: Proposes global automatic retries or increasing sleeps, or blames the CI machines without evidence.

SR-TH-TST-04 · A mocked database connection that accepts broken SQL

Medium & Tricky · Theory · Senior (5–8 years) · test doubles, mocks vs fakes, repository pattern, integration tests, sqlite3 · 6 min

Question: A teammate's PR adds overdue_invoices(conn, today) and unit-tests it by passing a MagicMock connection whose execute returns two rows. The test is green. What's your review comment?

Answer:

Trap: "Mocking the connection is fine; it's a unit test, and the DB is an external dependency."

The interesting behaviour of this function is the SQL. A mocked connection returns whatever you told it to, no matter what query string it receives, so the test only proves the code calls execute and reshapes tuples. In the snippet the query references a column that doesn't exist; the mock happily returns [7, 9], and a real SQLite database fails immediately. The same blindness covers wrong WHERE logic, parameter order, NULL handling, and the ORDER BY the caller relies on.

The principle: don't mock what you don't own, and don't mock the thing whose behaviour you're testing. My comment would ask for:

  • An integration test against the real engine for the repository function: seed a few rows (one overdue, one due today, one paid, one with a NULL due date) and assert the exact IDs and order. For Postgres, use the real Postgres version via testcontainers or a CI service container; SQLite differs in typing, date handling and SQL dialect, so it isn't a stand-in for Postgres in tests of SQL.
  • Unit tests one layer up use a fake repository, a small in-memory class with the same interface (list_overdue(today) -> list[int]), instead of a mock of the DB driver. Fakes have behaviour and stay honest; mocks of low-level APIs encode the implementation.
  • Mocks are still right for things you do own and where interaction is the behaviour (e.g. "the notifier was called once with this invoice ID"), with autospec so the mock can't drift from the real signature.

import sqlite3
from unittest import mock

def overdue_invoices(conn, today: str) -> list[int]:
    # Bug: the column is called due_date, not due_on.
    sql = "SELECT id FROM invoices WHERE due_on < ? AND status = 'open' ORDER BY id"
    return [row[0] for row in conn.execute(sql, (today,))]

conn = mock.MagicMock()
conn.execute.return_value = [(7,), (9,)]
print("mock:  ", overdue_invoices(conn, "2026-10-01"))

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE invoices (id INTEGER, due_date TEXT, status TEXT)")
db.execute("INSERT INTO invoices VALUES (7, '2026-09-01', 'open'), (9, '2026-09-15', 'open')")
try:
    print("sqlite:", overdue_invoices(db, "2026-10-01"))
except sqlite3.OperationalError as e:
    print("sqlite:", type(e).__name__, "-", e)

Output:

mock:   [7, 9]
sqlite: OperationalError - no such column: due_on

Follow-ups:

  1. "Real-database tests are slower. How do you keep them fast enough to run on every PR?" Start the container once per session, apply migrations once, wrap each test in a transaction (or a savepoint) that's rolled back, and parallelise with one database per xdist worker. Hundreds of such tests run in a minute or two. Keep the number of DB tests proportional to the amount of SQL, not to the number of endpoints.
  2. "When is a fake better than a real dependency, even for integration-style tests?" When the real thing is slow, costly or not under your control (a payments provider, an SMS gateway, a third-party API) and you need error cases on demand: timeouts, 5xx, partial failures. Then a fake at the HTTP layer (respx) or an in-memory implementation, plus a contract test that checks the fake still matches the real API.

Scoring: Strong: Identifies that the SQL is untested; asks for real-engine integration tests (and notes SQLite is not Postgres); uses fakes for callers and autospec mocks only where interaction is the behaviour. Acceptable: Says the test is weak and asks for a database test, but doesn't articulate when mocks are appropriate. Red flag: Approves because the test is green and isolated, or suggests asserting the exact SQL string passed to the mock.

SR-TH-TST-05 · Writing a deterministic test for a race condition

Hard · Theory · Senior (5–8 years) · concurrency testing, threading, race conditions, test seams, Barrier, lost update · 8 min

Question: A production incident shows wallet credits occasionally going missing under load. You suspect a read-modify-write race. How do you write a test that reproduces it every time, rather than "run it 10,000 times and hope"?

Answer:

A stress loop can show a race exists, but it's slow, machine-dependent, and passes most of the time even with the bug. A regression test needs to force the bad interleaving.

1. Find the window. The bug is a read, then a write based on that read, with other threads able to run in between. Here: current = self.balance ... self.balance = current + amount.

2. Add a seam to control scheduling. Inject a hook between read and write (a constructor argument, a patched method, or a fault-injection point that's a no-op in production). In the test, make it a threading.Barrier(2): both threads must read before either writes. That deterministically produces the lost update: two ₹50 credits on ₹100 end at ₹150, every run.

3. Prove the fix. With the read-modify-write under a lock, the barrier would deadlock (the second thread never reaches it), so the fix test uses a different seam: widen the window with a sleep, and the lock still gives ₹200. In a real codebase, the fix is often not a Python lock at all, because the race spans processes and hosts: use an atomic DB update (UPDATE wallets SET balance = balance + :amt), SELECT ... FOR UPDATE, or a version check. Then the deterministic test runs two connections against a real DB with the same pause between their transactions' read and write.

Other tools for concurrency testing:

  • asyncio: control interleaving with asyncio.Events, or a fake clock; races in async code happen only at await points, which makes them easier to pin down.
  • For thread-heavy code, run the suite with sys.setswitchinterval(1e-6) to make switches far more frequent, as a cheap stress amplifier.
  • Always join threads with a timeout in tests, so a deadlock fails the test instead of hanging CI.

import threading
import time

class Wallet:
    def __init__(self, balance: int, pause=lambda: None):
        self.balance = balance
        self._pause = pause                  # test seam between read and write
        self._lock = threading.Lock()

    def credit_racy(self, amount: int) -> None:
        current = self.balance               # read
        self._pause()
        self.balance = current + amount      # write

    def credit(self, amount: int) -> None:
        with self._lock:                     # read-modify-write is now atomic
            current = self.balance
            self._pause()
            self.balance = current + amount

def two_credits(wallet: Wallet, method: str) -> int:
    threads = [threading.Thread(target=getattr(wallet, method), args=(50,)) for _ in range(2)]
    for t in threads: t.start()
    for t in threads: t.join()
    return wallet.balance

# A barrier makes both threads read before either writes: the bad interleaving, every run.
barrier = threading.Barrier(2)
print("racy, forced interleaving:", two_credits(Wallet(100, pause=barrier.wait), "credit_racy"))
# The locked version can't use the barrier (the second thread never reaches it), so widen the
# window with a sleep instead: the lock still gives the right answer.
print("locked, widened window:   ", two_credits(Wallet(100, pause=lambda: time.sleep(0.05)), "credit"))

Output:

racy, forced interleaving: 150
locked, widened window:    200

Follow-ups:

  1. "Isn't self.balance += amount atomic in CPython because of the GIL?" No. It compiles to separate load, add and store bytecodes, and a thread switch can happen between them. The GIL protects the interpreter's internals, not your invariants. Free-threaded builds (3.13t and later) make relying on accidental atomicity even riskier.
  2. "Adding a test seam to production code: is that acceptable?" Yes, if it's small and explicit: an injected hook or dependency with a no-op default costs nothing at runtime and documents where the critical section is. Monkeypatching private internals from the test is the more fragile alternative.

Scoring: Strong: Forces the interleaving with a seam and a barrier or events; knows a stress loop isn't a regression test; moves the fix to the database when the race spans processes; joins with timeouts. Acceptable: Identifies the read-modify-write race and fixes it with a lock; tests it with a loop over many iterations. Red flag: Believes the GIL makes += atomic, or tests races with sleep() and repetition and calls that deterministic.

SR-TH-TST-06 · Green CI, broken wheel

Hard & Tricky · Theory · Senior (5–8 years) · packaging, src layout, testing installed artifacts, package data, release pipeline · 7 min

Question: Your team publishes an internal Python SDK to the company's package index. CI is green on every release, yet version 2.4.0 fails on import for every consumer with FileNotFoundError for a bundled JSON schema, and 2.4.1 fails with ModuleNotFoundError: No module named 'tenacity'. How did the tests miss both, and what do you change?

Answer:

Trap: "The tests must be incomplete; add tests for the schema loader and the retry code."

The tests did exercise that code. They just didn't test the artifact that was shipped. Two classic gaps:

  1. Tests import the source tree, not the installed package. With a flat layout (mysdk/ next to tests/), the repo root ends up on sys.path (running python -m pytest from it, a tests/__init__.py, or an editable pip install -e . in CI), so import mysdk loads the checkout, where mysdk/schemas/order.json exists on disk. The wheel didn't include it because non-Python files need to be declared (package data settings in pyproject.toml, or the build backend's include rules), so consumers got a package without the file.
  2. The test environment isn't the consumer's environment. tenacity was installed in CI via requirements-dev.txt but never declared in [project] dependencies. Everything worked in CI; a clean pip install mysdk didn't pull it in.

What I'd change, as a team standard for anything we publish:

  • src/ layout (src/mysdk/), so the package is importable only once installed; tests can no longer accidentally use the checkout.
  • CI builds the sdist and wheel once, installs the wheel into a fresh venv with only its declared dependencies (plus the test runner), and runs the tests against that. nox/tox do this naturally. Publish exactly that tested wheel; don't rebuild at publish time.
  • Inspect the wheel: assert the expected files are in it (unzip -l, check-wheel-contents), and load data with importlib.resources.files("mysdk") / "schemas/order.json" rather than paths relative to __file__ strings.
  • Test against the supported matrix (oldest supported Python and oldest allowed versions of dependencies, not just the latest), since consumers will resolve differently than your lock file.
  • A smoke test after publishing: pip install mysdk==X from the index in a clean container and import it.

Follow-ups:

  1. "How should an SDK declare its dependencies: pinned exactly, or as ranges?" Ranges with a floor you test against (httpx>=0.27,<1) for a library; exact pins belong in applications and lock files. Pinning exactly in a library causes resolver conflicts for every consumer that also uses httpx. Test the floor and the latest in CI.
  2. "You've released a broken 2.4.0. What do you do on the index?" Release a fixed 2.4.2 quickly and yank 2.4.0 and 2.4.1 (PEP 592), so resolvers skip them unless pinned exactly. Never re-upload a different file under the same version: PyPI refuses it, a private index should be configured to, and caches and lock-file hashes would disagree about what 2.4.0 is. Announce it to consumers.

Scoring: Strong: Explains that tests ran against the checkout and the dev environment rather than the wheel; prescribes src layout, build-once-test-the-wheel-in-a-clean-venv, importlib.resources, and a version matrix; knows to yank rather than overwrite. Acceptable: Identifies the missing package data and undeclared dependency, and suggests testing the installed package, without a concrete pipeline. Red flag: Adds more unit tests, or fixes it by adding tenacity to the consumers' requirements and copying the JSON next to their code.

SR-TH-TYP-01 · Rolling out type checking in an existing service

Easy · Theory · Senior (5–8 years) · typing, mypy, pyright, CI, gradual typing, team standards · 5 min

Question: Your team owns a 60k-line Django/FastAPI codebase with almost no type hints, and you've been asked to "add type checking". How do you roll it out so it actually catches bugs without stalling everyone?

Answer:

Treat it as a migration with a ratchet, not a big-bang:

  • Pick one checker and pin its version (mypy or pyright) in dev dependencies and CI, with the config in pyproject.toml. Upgrades are deliberate PRs, because new versions find new errors.
  • Start permissive globally, strict locally. Turn on checking for the whole repo with lenient settings, and enable strict options (disallow_untyped_defs, warn_return_any, no_implicit_optional, strict_optional) per package for new or well-owned modules. New code must be typed; old code is typed when touched.
  • Type the boundaries first: public functions of each module, request/response models, repository/DB layers and client SDKs. That's where wrong assumptions cross team lines and where annotations double as documentation.
  • Baseline and ratchet. Record the current errors (a baseline file, or per-module ignore_errors) so CI fails only on new errors, then shrink the baseline over time and track the count.
  • Control escape hatches. Require error codes on ignores (# type: ignore[attr-defined]), turn on warn_unused_ignores, and review Any/cast like you'd review except Exception. Install stub packages (types-requests, django-stubs) or mark untyped third-party imports explicitly.
  • Pair with runtime validation. Type hints aren't enforced at runtime; data entering the system (HTTP, queues, files) is validated with Pydantic at the boundary, so the inside can trust its types.

Measure success by bugs caught in review/CI (None handling, wrong argument types after refactors), not by annotation coverage.

Follow-ups:

  1. "A developer says type hints slow them down. How do you respond?" Agree that annotating internals of a 20-line script isn't worth much; the payoff is at module boundaries and during refactors, when the checker finds every caller you'd otherwise miss. Keep strictness proportional: strict for shared libraries and core domain code, lenient for scripts and tests.
  2. "What kinds of bugs does a type checker catch that tests often miss?" Unhandled None (Optional results used directly), wrong types after a refactor in rarely-executed branches, misspelled attributes, wrong argument order or count in call sites tests don't cover, and incompatible return types across layers.
  3. "And what does it not catch?" Wrong values of the right type (negative amounts, wrong units), anything passing through Any or cast, data from outside the process, and logic errors. That's why validation and tests remain.

Scoring: Strong: Describes an incremental rollout (pinned checker, per-module strictness, boundaries first, baseline and ratchet), governs escape hatches, and pairs static typing with runtime validation at boundaries. Acceptable: Proposes adding mypy to CI and annotating gradually, without a baseline/ratchet or escape-hatch policy. Red flag: Turns on --strict for the whole repo at once, or believes hints are enforced at runtime.

SR-TH-TYP-02 · cast() converts nothing

Easy & Tricky · Theory · Senior (5–8 years) · typing, cast, runtime validation, pydantic, TypeAdapter · 4 min

Question: To silence mypy on a parsed JSON payload, a teammate writes qty = cast(int, payload["qty"]) and moves on. mypy is now green. What did that line do at runtime, and what would you ask for instead?

Answer:

Trap: "cast(int, x) converts or at least checks the value, like int(x)."

typing.cast does nothing at runtime: it returns its argument unchanged. It only tells the type checker "trust me, this is an int". If the client sent "3", qty is still the string "3", the checker is now convinced otherwise, and the failure moves to wherever the value is used (qty + 1 raises TypeError, or worse, qty * 2 silently gives "33"). The checker can't help any more because you told it not to.

What to do instead depends on where the value comes from:

  • External data (JSON, query params, queues, files): validate and convert at the boundary, e.g. a Pydantic model for the payload, or TypeAdapter(int).validate_python(...) for a single value. You get a real int or a clear ValidationError.
  • Internal code where you know more than the checker: narrow with a check the checker understands: isinstance(x, int), assert x is not None for genuine invariants, or a TypeGuard/TypeIs function, which both narrows statically and checks at runtime.
  • Truly unavoidable casts (a library typed as returning Any or too broad a type): keep cast but close to the source, with a comment, and preferably wrap the library call in one typed function so the cast lives in one place.

In review, I treat cast like # type: ignore: allowed, but each one needs a reason.

import json
from typing import cast
from pydantic import TypeAdapter, ValidationError

payload = json.loads('{"order_id": "A1", "qty": "3", "discount": "ten"}')

qty = cast(int, payload["qty"])          # the checker now believes this is an int
print(type(qty).__name__, repr(qty))
try:
    print(qty + 1)
except TypeError as exc:
    print("TypeError:", exc)

as_int = TypeAdapter(int)
print(as_int.validate_python(payload["qty"]) + 1)
try:
    as_int.validate_python(payload["discount"])
except ValidationError as exc:
    print("ValidationError:", exc.errors()[0]["type"])

Output:

str '3'
TypeError: can only concatenate str (not "int") to str
4
ValidationError: int_parsing

Follow-ups:

  1. "What's the difference between TypeGuard and TypeIs for a user-defined narrowing function?" Both make a function returning bool narrow its argument when it returns True. TypeIs (3.13, or typing_extensions) also narrows the False branch and requires the narrowed type to be consistent with the input type; TypeGuard narrows only the True branch and can narrow to unrelated types (like list[object] to list[str]).
  2. "Pydantic converted "3" to 3. When would you want it not to?" When the contract says the field is a JSON number and coercion would hide a client bug, e.g. money or IDs. Use strict mode (strict=True on the model, field or TypeAdapter) so "3" is rejected.
  3. "Is cast ever needed with well-typed code?" Occasionally, for things the type system can't express (results of json.loads, generic containers from untyped libraries, some metaprogramming). The goal is few casts, each documented and close to the source.

Scoring: Strong: States that cast is a no-op at runtime that disables checking, moves the failure downstream, and replaces it with validation at boundaries (Pydantic/TypeAdapter) or real narrowing (isinstance, TypeIs) internally. Acceptable: Knows cast doesn't convert but doesn't offer a principled replacement. Red flag: Believes cast converts or validates the value.

SR-TH-TYP-03 · Using @overload when the return type depends on an argument

Medium · Theory · Senior (5–8 years) · typing, overload, Literal, API design · 5 min

Question: An SDK function fetch_invoice(invoice_id, raw=False) returns parsed Invoice objects by default and raw bytes (the PDF) when raw=True. It's annotated -> Invoice | bytes, and every caller now needs an isinstance check. How do you type it properly, and when would you redesign instead?

Answer:

Use typing.overload with Literal so the checker picks the return type from the argument:

from typing import Literal, overload

@overload
def fetch_invoice(invoice_id: str, raw: Literal[False] = ...) -> Invoice: ...
@overload
def fetch_invoice(invoice_id: str, raw: Literal[True]) -> bytes: ...
@overload
def fetch_invoice(invoice_id: str, raw: bool) -> Invoice | bytes: ...
def fetch_invoice(invoice_id: str, raw: bool = False) -> Invoice | bytes:
    ...  # single real implementation

How it works and what to watch:

  • The @overload stubs exist only for type checkers; at runtime only the last, undecorated definition remains. The implementation's own signature must accept every overload's arguments.
  • Overloads are matched in order, so put the most specific first. The final bool overload handles callers passing a variable (raw=flag) whose value isn't known statically.
  • Checkers verify that the implementation is compatible with each overload, but not that it actually returns bytes when raw=True, so test both paths.
  • typing.get_overloads() (3.11+) can retrieve them at runtime for documentation tools.

When to redesign instead: a boolean flag that changes the return type usually means two operations sharing a name. fetch_invoice() and fetch_invoice_pdf() are simpler to type, read and document, need no overloads, and let each evolve separately. I use overloads mostly for APIs I can't change, or for genuinely polymorphic functions like get(key) -> V | None vs get(key, default: V) -> V.

Follow-ups:

  1. "Why isn't TypeVar enough here?" A TypeVar ties the return type to the type of an argument (def first[T](xs: list[T]) -> T). Here the return type depends on the argument's value (True vs False), which needs Literal overloads.
  2. "What's the overload pattern for config.get(key) vs config.get(key, default)?" Two overloads: get(key: str) -> str | None and get[T](key: str, default: T) -> str | T. Callers who pass a default no longer have to handle None.
  3. "What breaks if the overloads drift from the implementation over time?" Callers get confidently wrong types: the checker trusts the overloads, not the body. Keep overloads next to the implementation and cover each variant with a test.

Scoring: Strong: Writes Literal-based overloads including the bool fallback, knows overloads are checker-only and ordered, tests both branches, and suggests splitting into two functions where the API can change. Acceptable: Knows @overload exists and roughly how to use it but misses the non-literal bool case or the redesign option. Red flag: Suggests returning Any to silence callers, or thinks overload dispatches at runtime.

SR-TH-TYP-04 · NewType for paise and rupees is a static-only guard

Medium & Tricky · Theory · Senior (5–8 years) · typing, NewType, domain types, units, money · 5 min

Question: After a bug where rupees were passed where paise were expected (a ₹499 refund became ₹4.99), a teammate introduces Paise = NewType("Paise", int) and Rupees = NewType("Rupees", int). Does this prevent the bug from recurring? What does it do at runtime?

Answer:

Trap: "NewType creates a new subclass, so passing a plain int or the wrong unit fails at runtime."

NewType is a static-only distinction:

  • For the type checker, Paise is a distinct subtype of int: passing Rupees or a bare int where Paise is expected is an error, while a Paise can still be used anywhere an int is accepted. That catches exactly the unit-mix-up bug, in checked code.
  • At runtime, Paise(4990) is an identity function: it returns the same plain int. There's no new class, no validation (Paise(-5) is fine), isinstance(x, Paise) raises TypeError, and arithmetic returns plain int (Paise + Paise is typed as int, so results must be re-wrapped).

So it prevents recurrence only if: the code paths are type-checked in CI, values entering from outside (JSON, DB rows) are wrapped at the boundary in one place, and nobody launders values through Any or untyped helpers.

When you need runtime guarantees too (validation, units that can't be mixed even in untyped code, nice repr), use a small value class instead: e.g. a frozen dataclass Money(paise: int, currency: str) with constructor validation and explicit conversion methods. It costs an object per value, which is irrelevant for an API and noticeable in a hot loop over millions of rows (where you'd use arrays with a unit-labelled column anyway).

Naming helps either way: amount_paise in field and parameter names makes mix-ups visible in review even where types can't reach.

from typing import NewType

Paise = NewType("Paise", int)
Rupees = NewType("Rupees", int)

def to_paise(r: Rupees) -> Paise:
    return Paise(r * 100)

amt = to_paise(Rupees(499))
print(amt, type(amt).__name__, amt == 49900)
print(Paise)
try:
    isinstance(amt, Paise)
except TypeError as exc:
    print("TypeError:", exc)
print(Paise(-5))                     # no validation either

Output:

49900 int True
__main__.Paise
TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union
-5

Follow-ups:

  1. "How does NewType differ from a type alias like type Paise = int?" An alias is just another name: the checker treats Paise and int as identical, so it catches nothing. NewType creates a distinct static type that plain int is not assignable to.
  2. "How would you get Pydantic to enforce units at the API boundary?" Make the field name and type explicit (amount_paise: int with Field(ge=0) or a custom annotated type), or model money as a nested object with currency and minor units, and reject floats with strict mode.
  3. "Can you subclass a NewType?" No. Paise is not a class but an instance of typing.NewType (a callable that returns its argument), so class X(Paise) raises TypeError: Cannot subclass an instance of NewType. You can base one NewType on another instead: RefundPaise = NewType("RefundPaise", Paise).

Scoring: Strong: Explains static-only distinctness, identity at runtime (no validation, isinstance error, arithmetic returns int), the conditions under which it prevents the bug, and when a runtime value class is worth it. Acceptable: Knows it's checked only statically but can't describe its runtime behaviour or limits. Red flag: Believes NewType validates or creates a real subclass at runtime.

SR-TH-TYP-05 · Shipping type information in an internal library

Hard · Theory · Senior (5–8 years) · typing, py.typed, stubs, packaging, re-exports, library design · 7 min

Question: Your team publishes ledger-client to the internal package index. It's fully annotated, yet consumers report that mypy sees everything from it as Any, and after they enable stricter settings, imports like from ledger_client import LedgerClient produce errors. What's going on, and what does a well-typed library ship?

Answer:

Why everything is Any: type checkers only trust inline annotations in installed packages that declare themselves typed (PEP 561). Without a py.typed marker file in the package (and included in the built wheel), mypy treats the package as untyped: imports become Any, or mypy reports "module is installed, but missing library stubs or py.typed marker". So the annotations exist but are ignored.

Why imports fail under stricter settings: with --no-implicit-reexport (part of --strict), a name imported into ledger_client/__init__.py with a plain from ._client import LedgerClient is considered private. Public re-exports must be explicit: list them in __all__, or use the redundant alias form from ._client import LedgerClient as LedgerClient.

What a well-typed library ships:

  • py.typed in the package, verified in the wheel contents in CI.
  • An explicit public API: __all__, re-exports from the package root, internals in _private modules.
  • Annotations that are good for callers: accept abstract, read-only types (Iterable, Mapping, Sequence), return concrete types; Protocols for pluggable parts; no Any leaking from public functions (it silently disables checking downstream).
  • Types that work for the oldest Python version you support (e.g. from __future__ import annotations or typing_extensions for 3.9/3.10 consumers), and no runtime imports of type-only dependencies.
  • CI that type-checks the library as a consumer would: run mypy and pyright on a small test file that imports the installed wheel, plus stubtest if you ship separate .pyi stubs (for C extensions or generated code).
  • Treat type changes as API changes: narrowing an argument type or widening a return type can break consumers' CI, so it goes in the changelog and follows semver.

Follow-ups:

  1. "When would you ship .pyi stub files instead of inline annotations?" For compiled extensions (Cython, Rust/PyO3), code whose runtime signatures are generated dynamically, or when you can't change the library source. Stubs must be kept in sync; stubtest checks them against the runtime module.
  2. "A consumer uses pyright and you use mypy. Can they disagree?" Yes: inference, overload resolution and some strictness defaults differ. If both are used by consumers, run both in the library's CI on the public API.
  3. "Why is returning dict[str, Any] from a public SDK function a problem?" Every value a caller pulls out is Any, so typos and wrong types downstream are invisible to their checker. Return a typed model (dataclass, TypedDict or Pydantic model) instead.

Scoring: Strong: Identifies the missing py.typed marker and explicit re-export rules, and describes a full practice: public API definition, caller-friendly annotations, no Any leakage, consumer-side type-checking in CI, and treating type changes as API changes. Acceptable: Knows about py.typed but not the re-export issue or how to test types from the consumer's side. Red flag: Suggests consumers add ignore_missing_imports, or doesn't know how checkers find a library's types.

SR-TH-TYP-06 · A float annotation that accepts ints

Hard & Tricky · Theory · Senior (5–8 years) · typing, numeric tower, float, int, type checkers · 5 min

Question: def fingerprint(amount: float) -> str: return amount.hex() builds a dedup key from an amount's exact bit pattern. mypy is green across the codebase, yet production occasionally raises AttributeError: 'int' object has no attribute 'hex'. How did a type-checked codebase let an int through?

Answer:

Trap: "If mypy passes, amount is a float at runtime; int would be a type error."

Python's typing has a deliberate special case (from PEP 484's numeric tower shortcut): wherever an annotation says float, an int is also accepted, and complex accepts float and int. It exists so def area(r: float) can be called with 2 without ceremony. But int is not a subclass of float at runtime (isinstance(500, float) is False), and the two types don't share every method: float.hex() has no int counterpart (ints use the builtin hex(n), a different format), and int.is_integer() exists only from 3.12.

So a JSON payload {"amount": 500} (an int after json.loads), or any arithmetic that stayed integral, passes the checker and fails here.

How to handle it:

  • At boundaries, convert explicitly: float(amount) (or better, for money, Decimal(str(amount)) or integer paise), rather than trusting the annotation.
  • Inside the function, normalise if you genuinely accept both: amount = float(amount) at the top.
  • Be wary of other places where static types are looser than runtime: bool is accepted for int (and is an int subclass, so True + True == 2), bytearray/memoryview accepted for bytes under some checkers' default settings, and values flowing through Any.
  • For money specifically, this function is a symptom: deduplicating on a float's bit pattern means 499.99 computed two ways may produce different keys. Use integer minor units or a quantized Decimal string.

def fingerprint(amount: float) -> str:
    """Exact bit pattern, used in a dedup key."""
    return amount.hex()

print(fingerprint(499.5))
try:
    print(fingerprint(500))      # accepted for `float` by every type checker
except AttributeError as exc:
    print("AttributeError:", exc)
print(isinstance(500, float), isinstance(True, int))

Output:

0x1.f380000000000p+8
AttributeError: 'int' object has no attribute 'hex'
False True

Follow-ups:

  1. "Does the same promotion apply inside containers, e.g. passing list[int] to a parameter typed list[float]?" No. list is invariant, so list[int] is rejected for list[float] even though int is accepted for float; Sequence[float] (covariant) accepts list[int]. That inconsistency surprises people in both directions.
  2. "How would you write the annotation if the function really must receive a float?" There's no way to say "float but not int" in the standard type system. Convert at runtime (float(x)), or use a dedicated value type, or Pydantic strict mode at the boundary.
  3. "Why can bool sneak into numeric code unnoticed?" bool subclasses int, so True passes both static and runtime int checks (isinstance(True, int) is True). Validation code that means "a number" must exclude it explicitly, and Pydantic strict int also rejects bools.

Scoring: Strong: Knows the int-to-float (and float-to-complex) promotion rule in type checkers, that it isn't a runtime subclass relation, gives concrete method differences, and recommends explicit conversion at boundaries; notes the money/dedup design smell. Acceptable: Suspects int sneaks through via JSON but doesn't know the checker explicitly allows it. Red flag: Insists mypy must be misconfigured or that int is a subclass of float.