Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
You just designed a reasoning agent that budgets test-time compute and recovers from failed tool calls. A lab coding round asks a smaller question: can you implement one slice of that system while the requirements keep moving?
You're in a shared editor with a starting URL and a get_links callback. Twelve minutes later they want worker fanout. Then a per-host rate limit. Then cancellation. People who stall didn't forget BFS. They lost the visited invariant the moment the second requirement arrived.
OpenAI's engineering interviews look for well-designed solutions, high-quality code, performance, and test coverage, plus communication you can follow while you work.[1] Anthropic's technical process uses live coding tools such as Colab and CodeSignal; they expect you to be fluent in syntax and the standard library so lookup time doesn't eat the round, and they'll ask about experience and motivation.[2] Formats vary by team. Practice that bar with one base prompt and staged follow-ups: TTLs, concurrency, cancellation, rate limits, deterministic output, and a state model that still makes sense when the next constraint lands.
Keep one invariant visible
Use this loop for every prompt:
- Restate input, output, and failure behavior.
- Ship version 1 with the smallest correct state model.
- Add table-driven tests before adding stage 2.
- Isolate shared mutable state before adding threads.
- End by naming complexity, race risks, and production hardening.
The code that survives a staged round is the version whose visited set, TTL record, or ledger key you can still defend after the next requirement lands.
| Time | Target |
|---|---|
| 0-5 min | clarify ordering, failure behavior, and mutable state |
| 5-20 min | level 1 complete with tests |
| 20-32 min | level 2 or 3 complete without rewriting |
| 32-38 min | edge tests and complexity |
| final minutes | name the next follow-up, the race risk, and what you'd harden in production |
What is the common failure mode in staged coding rounds?
Answer
Losing the state model. People often solve version 1, then bolt on TTLs, transactions, or concurrency until nobody owns visited, the TTL record, or the ledger key. Keep those structures explicit and add one invariant at a time.
Python tools to know cold
Know these without documentation:
| Need | Python building block |
|---|---|
| FIFO work queue | collections.deque, queue.Queue, asyncio.Queue |
| Counts and top errors | collections.Counter |
| LRU cache | collections.OrderedDict |
| Deadlines and TTL | time.monotonic, injected now function |
| Priority scheduling | heapq, queue.PriorityQueue |
| Thread safety | threading.Lock, threading.RLock, threading.Condition, threading.Event |
| Worker fanout | concurrent.futures.ThreadPoolExecutor, as_completed |
| Parsing | splitlines, re, explicit state machines |
Don't wait for a test framework. Write a small run_tests() and use plain assert.
Why inject now instead of calling time.sleep() in a TTL or rate-limit interview test?
Answer
Injected time makes edge cases deterministic. You can test expiry, refill, and retry-after math immediately instead of slowing the interview down or creating flaky tests.
Same-host crawler
The running example is a CI-docs crawler. You're given https://repo.example/start and get_links(url) -> list[str]. Visit only URLs on the same host. Return them in deterministic breadth-first order for the single-threaded version. Don't visit the same URL twice. Ignore malformed URLs and off-host URLs.
Clarifying questions to ask before you type:
- Should URLs be normalized for fragments, trailing slashes, query strings, and redirects?
- Should failed fetches be retried, skipped, or returned separately?
- Does deterministic order still matter after worker fanout, or only for the single-threaded version?
The invariant is: a URL enters the queue only after it has been accepted into visited. Put host checks and that claim before fetch. Relative links such as /tests need urljoin so they become https://repo.example/tests.

1from collections import deque
2from urllib.parse import urlparse, urljoin
3
4PAGES = {
5 "https://repo.example/start": ["/tests", "/ci", "https://other.example/x"],
6 "https://repo.example/tests": ["/ci"],
7 "https://repo.example/ci": [],
8}
9
10def crawl(start_url: str, get_links) -> list[str]:
11 start = urlparse(start_url)
12 if not start.scheme or not start.netloc:
13 return []
14
15 visited = {start_url}
16 queue = deque([start_url])
17 ordered: list[str] = []
18
19 while queue:
20 url = queue.popleft()
21 ordered.append(url)
22
23 for raw_link in get_links(url):
24 candidate = urljoin(url, raw_link)
25 parsed = urlparse(candidate)
26 if parsed.scheme not in {"http", "https"}:
27 continue
28 if parsed.netloc != start.netloc:
29 continue
30 if candidate in visited:
31 continue
32 visited.add(candidate)
33 queue.append(candidate)
34
35 return ordered
36
37visited_order = crawl("https://repo.example/start", lambda url: PAGES.get(url, []))
38print(visited_order)
39assert visited_order == [
40 "https://repo.example/start",
41 "https://repo.example/tests",
42 "https://repo.example/ci",
43]1['https://repo.example/start', 'https://repo.example/tests', 'https://repo.example/ci']/tests is enqueued before /ci, so BFS returns start, tests, then ci. The off-host https://other.example/x never enters visited. When tests later points at /ci, the claim already happened, so you don't fetch it twice.
That single-threaded version still has a hole once two workers can call get_links at the same time. If you fetch first and mark visited later, both workers can pick /ci.

Protect the claim step, not the whole fetch. Deterministic return order gets expensive once workers run in parallel, unless the prompt still requires it. Say that out loud before you add a global lock just to keep BFS order.
1from threading import Lock
2
3visited = {"https://repo.example/start"}
4visited_lock = Lock()
5
6def claim(candidate: str) -> bool:
7 with visited_lock:
8 if candidate in visited:
9 return False
10 visited.add(candidate)
11 return True
12
13print(claim("https://repo.example/ci"))
14print(claim("https://repo.example/ci"))
15print(sorted(visited))1True
2False
3['https://repo.example/ci', 'https://repo.example/start']For rate limiting, add a per-host token bucket or next-allowed timestamp before fetch. For cancellation, check a threading.Event before scheduling new work, before fetching, and after each fetch before enqueueing children. Name all three behaviors: duplicate prevention, bounded fetch concurrency, and a cooperative stop.
Claim-before-fetch still leaves failed fetches stuck unless you model more than one terminal state:
| State | Meaning | Retry? |
|---|---|---|
in_flight | Claimed, fetch not finished | No second worker |
visited / success | Content processed | No |
failed / retry set | Hard failure with policy remaining | Yes, under backoff |
When the interviewer asks about failed fetches, say: the claim still happens under the lock, but success and permanent failure are different outcomes. A production sketch keeps visited for terminal success, in_flight for active work, and a retry queue with attempt counts so a timeout doesn't become permanent silence.
What invariant should you say before adding threads to a crawler, scheduler, or cache?
Answer
Name the shared state and the exact claim point. For a crawler, a URL is inserted into visited while holding the lock before any worker can fetch or enqueue it.
The crawler taught claim-before-mutate. The next prompt asks the same ownership question on a store: every public read has to observe expiry, not just get.
TTL key/value store
Implement an in-memory store with set(key, value, ttl=None), get(key), delete(key), and scan(prefix). ttl is in seconds. Expired keys behave as missing. Tests must not call sleep.
Ask:
- Should
scan(prefix)return keys, values, or key/value pairs? - Is a key expired when
expires_at == now, or only whenexpires_at < now? - Should compare-and-set treat an expired key as missing?
Inject now so tests can jump the clock. Store expires_at beside each value. The invariant is: every public read path either returns a non-expired value or removes the expired key. This store treats equality as expired (expires_at <= now), so a key set at time 10.0 with ttl=5.0 is gone at 15.0.
1from dataclasses import dataclass
2from typing import Callable
3
4@dataclass
5class Entry:
6 value: str
7 expires_at: float | None
8
9class Store:
10 def __init__(self, now: Callable[[], float]) -> None:
11 self.now = now
12 self.items: dict[str, Entry] = {}
13
14 def set(self, key: str, value: str, ttl: float | None = None) -> None:
15 expires_at = None if ttl is None else self.now() + ttl
16 self.items[key] = Entry(value, expires_at)
17
18 def get(self, key: str) -> str | None:
19 entry = self.items.get(key)
20 if entry is None:
21 return None
22 if entry.expires_at is not None and entry.expires_at <= self.now():
23 self.items.pop(key, None)
24 return None
25 return entry.value
26
27 def delete(self, key: str) -> None:
28 self.items.pop(key, None)
29
30 def scan(self, prefix: str) -> list[str]:
31 return sorted(
32 key
33 for key in list(self.items)
34 if key.startswith(prefix) and self.get(key) is not None
35 )
36
37clock = {"now": 10.0}
38store = Store(lambda: clock["now"])
39store.set("task:1", "running", ttl=5.0)
40store.set("task:2", "ready")
41print(store.get("task:1"), store.scan("task:"))
42clock["now"] = 15.0
43print(store.get("task:1"), store.scan("task:"))1running ['task:1', 'task:2']
2None ['task:2']scan calls get, so expiry cleanup isn't a get-only side path. Snapshot list(self.items) first; get may pop keys while you iterate.
Compare-and-set and locking
Compare-and-set must clean any expired value and compare while holding the same lock. The compact example below omits TTL so the atomic boundary is easy to see:
1from threading import RLock
2
3class AtomicStore:
4 def __init__(self) -> None:
5 self.items: dict[str, str] = {}
6 self.lock = RLock()
7
8 def compare_and_set(self, key: str, expected: str | None, value: str) -> bool:
9 with self.lock:
10 if self.items.get(key) != expected:
11 return False
12 self.items[key] = value
13 return True
14
15store = AtomicStore()
16print(store.compare_and_set("task:1", None, "running"))
17print(store.compare_and_set("task:1", None, "ready"))
18print(store.compare_and_set("task:1", "running", "ready"))1True
2False
3TrueFor the TTL store, wrap every public method with the same lock. If compare_and_set() calls get() and set() while holding that lock, use an RLock or split the internal helpers so the lock is acquired once. What you have to defend is atomicity: no other thread can change the key between compare and set.
Expiry is a time boundary on one record. A scheduler adds a graph: some tasks are ready, some retry, and some never run.
Task scheduler with retries
Schedule CI tasks with dependencies. A task becomes runnable when all dependencies have completed. Detect dependency cycles before running anything. Run ready tasks in priority order (lower number first). Retry failed tasks up to max_attempts. Return completed tasks, permanently failed tasks, and blocked dependents separately.
Ask:
- Does a lower priority number run first, or does a higher number win?
- If a dependency permanently fails, should dependents be marked failed, skipped, or blocked?
- Are retries immediate, delayed, or scheduled with backoff?
Represent the graph explicitly: dependents[task] lists tasks released by this task, and remaining[task] counts unmet dependencies. Use heapq for priority. The invariant is: a task enters the heap only when remaining[task] == 0. Three-state DFS (0 unseen, 1 active, 2 done) rejects a back edge to an active node in O(V + E) before run is ever called.
1import heapq
2from collections import defaultdict
3from dataclasses import dataclass
4
5@dataclass(frozen=True)
6class Task:
7 name: str
8 priority: int
9 deps: tuple[str, ...] = ()
10
11def schedule(tasks: list[Task], run, max_attempts: int = 2) -> tuple[list[str], list[str], list[str]]:
12 if max_attempts <= 0:
13 raise ValueError("max_attempts must be positive")
14 by_name = {task.name: task for task in tasks}
15 if len(by_name) != len(tasks):
16 raise ValueError("duplicate task name")
17 dependents: dict[str, list[str]] = defaultdict(list)
18 remaining = {task.name: len(task.deps) for task in tasks}
19
20 for task in tasks:
21 for dep in task.deps:
22 if dep not in by_name:
23 raise ValueError(f"unknown dependency: {dep}")
24 dependents[dep].append(task.name)
25
26 state: dict[str, int] = {}
27
28 def visit(name: str) -> None:
29 marker = state.get(name, 0)
30 if marker == 1:
31 raise ValueError("cycle")
32 if marker == 2:
33 return
34 state[name] = 1
35 for dep in by_name[name].deps:
36 visit(dep)
37 state[name] = 2
38
39 for name in by_name:
40 visit(name)
41
42 ready = [(task.priority, task.name) for task in tasks if remaining[task.name] == 0]
43 heapq.heapify(ready)
44 attempts = defaultdict(int)
45 completed: list[str] = []
46 failed: list[str] = []
47
48 while ready:
49 _, name = heapq.heappop(ready)
50 attempts[name] += 1
51 if not run(name):
52 if attempts[name] < max_attempts:
53 heapq.heappush(ready, (by_name[name].priority, name))
54 else:
55 failed.append(name)
56 continue
57
58 completed.append(name)
59 for child in dependents[name]:
60 remaining[child] -= 1
61 if remaining[child] == 0:
62 heapq.heappush(ready, (by_name[child].priority, child))
63
64 blocked = sorted(set(by_name) - set(completed) - set(failed))
65 return completed, failed, blocked
66
67tasks = [
68 Task("docs", priority=1),
69 Task("test", priority=2),
70 Task("deploy", priority=3, deps=("test",)),
71]
72fail_once = {"test"}
73
74def run_with_one_retry(name: str) -> bool:
75 if name in fail_once:
76 fail_once.remove(name)
77 return False
78 return True
79
80print(schedule(tasks, run_with_one_retry))
81print(schedule([Task("test", 1), Task("deploy", 2, ("test",))], lambda _: False, max_attempts=1))
82try:
83 schedule(
84 [Task("lint", 1, ("test",)), Task("test", 1, ("lint",))],
85 lambda _: True,
86 )
87except ValueError as error:
88 print(error)1(['docs', 'test', 'deploy'], [], [])
2([], ['test'], ['deploy'])
3cycledocs and test start ready. Priority 1 runs first, so docs completes. test fails once, retries, then succeeds and releases deploy. In the second call, test is permanently failed after one attempt, so deploy is blocked: it never ran. The cycle lint ↔ test is rejected before any run call, including for unrelated ready work.
Keep permanently failed tasks separate from blocked dependents. A blocked task never ran; callers may skip it, surface the failed prerequisite, or retry after repair.
Worker fanout
Keep graph construction and cycle detection single-threaded. Then protect only shared scheduler state: ready heap, attempts, completed, failed, and remaining dependency counts. Worker threads can run tasks outside the lock, then reacquire the lock to publish success or failure and release dependents.
If asked about retries, say whether retries preserve priority or use backoff. A concise full answer: ready tasks are claimed under a condition variable, execution happens outside the lock, and completion updates notify workers when new tasks become ready.
The crawler still needs a quota. That's the next drill: refill math with an injected clock, no sleep.
Token bucket with deterministic time
Rate limiters show up because they combine state, boundary conditions, and overload behavior. Retry storms get worse when every client wakes at the same backoff; jitter spreads those retries.[3] Inject now so the refill test can jump to 0.5s without sleeping.
The bucket below starts full. allow refills from elapsed time, then either consumes cost or returns how long the caller should wait. Impossible costs fail immediately instead of returning retry advice that can never succeed.
1import math
2from dataclasses import dataclass
3
4@dataclass
5class Bucket:
6 capacity: float
7 refill_per_second: float
8 tokens: float
9 updated_at: float
10
11class TokenBucketLimiter:
12 def __init__(self, capacity: int, refill_per_second: float) -> None:
13 if capacity <= 0:
14 raise ValueError("capacity must be positive")
15 if not math.isfinite(refill_per_second) or refill_per_second <= 0:
16 raise ValueError("refill_per_second must be positive and finite")
17 self.capacity = float(capacity)
18 self.refill_per_second = float(refill_per_second)
19 self._buckets: dict[str, Bucket] = {}
20
21 def allow(self, key: str, now: float, cost: float = 1.0) -> tuple[bool, float]:
22 if not math.isfinite(now):
23 raise ValueError("now must be finite")
24 if not math.isfinite(cost) or not 0 < cost <= self.capacity:
25 raise ValueError("cost must be greater than zero and no larger than capacity")
26 bucket = self._buckets.get(key)
27 if bucket is None:
28 bucket = Bucket(self.capacity, self.refill_per_second, self.capacity, now)
29 self._buckets[key] = bucket
30 if now < bucket.updated_at:
31 raise ValueError("now must not move backwards")
32
33 elapsed = max(0.0, now - bucket.updated_at)
34 bucket.tokens = min(bucket.capacity, bucket.tokens + elapsed * bucket.refill_per_second)
35 bucket.updated_at = now
36
37 if bucket.tokens >= cost:
38 bucket.tokens -= cost
39 return True, 0.0
40
41 missing = cost - bucket.tokens
42 retry_after = missing / bucket.refill_per_second
43 return False, retry_after
44
45limiter = TokenBucketLimiter(capacity=3, refill_per_second=1.0)
46print([limiter.allow("org-a", now=0.0)[0] for _ in range(4)])
47print(limiter.allow("org-a", now=0.5))
48print(limiter.allow("org-a", now=1.0))
49try:
50 limiter.allow("org-a", now=2.0, cost=4.0)
51except ValueError as error:
52 print(error)1[True, True, True, False]
2(False, 0.5)
3(True, 0.0)
4cost must be greater than zero and no larger than capacity
The key can be a user, organization, endpoint, or model. Cleanup for idle buckets is a production memory concern, not a correctness requirement for the base prompt. A thread-safe version needs a lock around _buckets and bucket mutation.
Denied quota calls get retried. If the retry is a debit against an org's remaining credits, "run it again" is the wrong model. You need an idempotency key.
Credit ledger with idempotency
Keep credit-like state consistent: org token budgets, GPU-second reservations, or eval-run spend. Use append-only events when you can. If you also keep a balance, update the balance and the event together.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Event:
5 idempotency_key: str
6 account: str
7 delta: int
8 balance_after: int
9
10class Ledger:
11 def __init__(self) -> None:
12 self.balance: dict[str, int] = {}
13 self.events: list[Event] = []
14 self.results: dict[str, Event] = {}
15
16 def apply(self, key: str, account: str, delta: int) -> int:
17 if key in self.results:
18 prior = self.results[key]
19 if (prior.account, prior.delta) != (account, delta):
20 raise ValueError("idempotency key reused with different operation")
21 return prior.balance_after
22 new_balance = self.balance.get(account, 0) + delta
23 if new_balance < 0:
24 raise ValueError("insufficient credits")
25 self.balance[account] = new_balance
26 event = Event(key, account, delta, new_balance)
27 self.events.append(event)
28 self.results[key] = event
29 return new_balance
30
31ledger = Ledger()
32print(ledger.apply("deposit-1", "org-a", 100))
33print(ledger.apply("withdraw-1", "org-a", -30))
34print(ledger.apply("deposit-1", "org-a", 100))
35print(ledger.balance["org-a"], len(ledger.events))
36try:
37 ledger.apply("deposit-1", "org-a", 200)
38except ValueError as error:
39 print(error)1100
270
3100
470 2
5idempotency key reused with different operationA replay with the same key and operation returns the stored result, even if the org's balance changed later. Reusing a key for a different operation fails loudly instead of silently returning an unrelated balance. That's idempotency: the request key names the original effect, not "try the debit again."
Transfer follow-up:
- Use one idempotency key for the whole transfer.
- Lock account IDs in sorted order to avoid deadlock.
- Record both debit and credit events together.
- Define whether external side effects happen before or after durable commit.
A reusable lock-order helper makes deadlock prevention concrete:
1from contextlib import ExitStack
2from threading import Lock
3
4locks = {"org-a": Lock(), "org-b": Lock()}
5
6def lock_accounts(*account_ids: str) -> ExitStack:
7 stack = ExitStack()
8 for account_id in sorted(set(account_ids)):
9 stack.enter_context(locks[account_id])
10 return stack
11
12with lock_accounts("org-b", "org-a"):
13 print("locked:", sorted({"org-b", "org-a"}))1locked: ['org-a', 'org-b']Crawlers, stores, schedulers, and ledgers are all mutable state plus an explicit claim. Parsers are the other common shape: an explicit current record and a policy for junk lines.
Multiline log parser
Group indented continuation lines under the previous valid event. Ignore orphan continuations and malformed records in this base version; a production parser should count or collect rejects.
1import json
2
3def parse_events(text: str) -> list[dict[str, str]]:
4 events: list[dict[str, str]] = []
5 current: dict[str, str] | None = None
6
7 for line in text.splitlines():
8 if line.startswith(" "):
9 if current is not None:
10 current["message"] += f" {line.strip()}"
11 continue
12
13 parts = line.split("|", maxsplit=2)
14 if len(parts) != 3:
15 current = None
16 continue
17
18 timestamp, level, message = parts
19 current = {"timestamp": timestamp, "level": level, "message": message}
20 events.append(current)
21
22 return events
23
24LOGS = """ orphan continuation
252026-06-02T12:00:00Z|ERROR|request failed
26 timeout while calling model
27malformed line
282026-06-02T12:01:00Z|INFO|retry queued"""
29
30print(json.dumps(parse_events(LOGS), indent=2))1[
2 {
3 "timestamp": "2026-06-02T12:00:00Z",
4 "level": "ERROR",
5 "message": "request failed timeout while calling model"
6 },
7 {
8 "timestamp": "2026-06-02T12:01:00Z",
9 "level": "INFO",
10 "message": "retry queued"
11 }
12]The leading indented line is dropped because there's no current event. The malformed non-indented line isn't an event and clears current, so a later indented line wouldn't attach to the ERROR record. Ask whether tabs count as continuation; this version only treats a leading space as a continuation.
Once the state model is right, interviewers often say "make it faster with threads." That follow-up is a concurrency-model question, not a request to sprinkle ThreadPoolExecutor on a CPU loop.
When threads help
Before you reach for threads or async, name what kind of work you're speeding up. That one sentence is a strong signal because it shows you understand why a concurrency model helps or doesn't.
On a standard GIL-enabled CPython build, only one thread executes Python bytecode at a time. Optional free-threaded CPython builds, available since 3.13, change that constraint, so state which runtime you're discussing.[4] Free-threading isn't data-race freedom: shared mutable state still needs locks, atomics, or other synchronization. The official howto recommends threading.Lock instead of relying on the internal locks of built-in dicts and sets. Dropping locks because "there is no GIL" is still wrong.
| Work type | Example | GIL effect | Right tool |
|---|---|---|---|
| I/O-bound | fetching URLs, reading disk, calling a model API | blocking I/O generally releases the GIL, so threads can overlap waits | threads, ThreadPoolExecutor, or asyncio |
| CPU-bound Python | heavy parsing or numeric loops in Python code | threads take turns under a GIL-enabled build | multiprocessing or ProcessPoolExecutor |
So the honest answer to "should we add threads?" is: only if the bottleneck is waiting, not computing. A crawler that spends its time on network round-trips is a good fit for threads. A function that spends its time crunching numbers in pure Python won't get faster with threads; it needs separate processes, or a library that drops into C and releases the GIL.
⚠️ Common mistake: Reaching for
asyncioand then calling a blocking function inside a coroutine. Synchronous calls likerequests.get(...)ortime.sleep(...)don't yield to the event loop, so a single blocking line freezes every other task. The whole point ofasyncis that eachawaithands control back so other work can run.
Each worker yields at await asyncio.sleep(0), so the event loop interleaves them:
1import asyncio
2
3async def worker(name: str, log: list[str]) -> None:
4 log.append(f"{name} start")
5 await asyncio.sleep(0) # yields control back to the event loop
6 log.append(f"{name} resume")
7
8async def main() -> list[str]:
9 log: list[str] = []
10 await asyncio.gather(worker("a", log), worker("b", log))
11 return log
12
13print(asyncio.run(main()))1['a start', 'b start', 'a resume', 'b resume']Swap await asyncio.sleep(0) for a blocking time.sleep(0) and the output becomes ['a start', 'a resume', 'b start', 'b resume']: worker a runs start-to-finish before b ever begins, because a blocking call never returns control to the loop.
An interviewer asks you to make a log parser "faster with threads." The parser is pure-Python string processing over one large file. What do you say?
Answer
Say it's CPU-bound, so threads won't help under the GIL; they'll just take turns on one core. The real options are multiprocessing to use multiple cores, or dropping the hot loop into a C-backed library that releases the GIL. If the file were being fetched over the network, threads would help because that part is I/O-bound.
Before adding workers, say these five sentences:
- Shared state is
X. - The lock protects
X. - A work item is claimed at this point.
- Worker shutdown happens through this sentinel, event, or executor lifecycle.
- Failed work records an error and doesn't corrupt shared state.
Debugging when a test fails
Narrate the smallest useful investigation:
- Read the assertion and say expected versus actual.
- Reproduce with one smaller fixture.
- Print or inspect the state that owns the invariant.
- Fix the state transition, not the symptom.
- Add one regression test before moving to the next follow-up.
| Symptom | First thing to inspect |
|---|---|
| Duplicate output | claim point or visited insertion timing |
| Missing output | enqueue condition, prefix filter, or failure policy |
| Wrong order | queue/heap tie-break and where sorting happens |
| Expired key returned | read path skipped cleanup |
| Retry ran too many times | attempt counter increment point |
| Blocked task marked failed | dependency failure policy mixed with task execution failure |
| Threaded result flakes | shared state changed outside the lock |
You don't have to be bug-free. Recover fast by naming which invariant broke: visited claimed too late, a TTL read skipped cleanup, or a lock dropped between compare and set.
When the interviewer adds a follow-up, classify it before changing code.
| Follow-up type | First move | Common trap |
|---|---|---|
| Time | inject now, store deadline, clean on read | sleeping in tests |
| Ordering | choose deque, heap, sort, or insertion order | mixing policy into unrelated state |
| Capacity | evict by explicit rule | hidden off-by-one at capacity 0 or 1 |
| Retry | store attempt count and final state | retrying external writes without idempotency |
| Snapshot | version records or copy-on-write | mutating data a snapshot should freeze |
| Compare-and-set | lock compare and write together | calling get then set as two operations |
| Thread safety | protect claim point and shared maps | holding lock during slow fetch or execution |
| Cancellation | durable flag plus cooperative checks | stopping new work while workers keep publishing children |
Pattern map
Most prompts are one of these shapes. Classify the prompt before writing code.
| Pattern | Recognition signal | First state model | High-value tests |
|---|---|---|---|
| Traversal | URLs, graph nodes, dependencies, neighbors | visited plus queue or stack | cycle, duplicate edge, malformed node, deterministic order |
| Mutable store | set, get, delete, scan, cache records | dict from key to record | missing key, overwrite, delete, scan ordering, expired key |
| Time boundary | TTL, deadline, rate limit, retry-after | injected now, deadline fields | equality boundary, refill edge, expired-on-read, no sleep |
| Ordering policy | priority, deadline, LRU, LFU, ready queue | heap, deque, OrderedDict, frequency buckets | tie-break, stale heap entry, capacity 0, promotion |
| Idempotent write | request ID, retry, ledger, external action | request key to stored result | duplicate success, duplicate failure, conflicting retry |
| Parser | logs, events, chunks, sections, streaming lines | explicit current record or buffer | malformed line, continuation, empty input, final flush |
| Concurrency | workers, thread-safe, fanout, cancellation | lock-protected claim point plus queue | duplicate claim, shutdown, exception, partial result |
| Validation | schema, payload, permissions, filters | recursive validator or rule table | nested failure, path reporting, unknown field, missing required |
Many rounds feel like one product-shaped problem that grows across levels. Ship level 1 quickly, then preserve the same invariant.
| Prompt family | Level 1 | Level 2 | Level 3 | Level 4 |
|---|---|---|---|---|
| GPU quota reservation | add, remove, lookup | reserve/release capacity | expirations, over-allocation prevention | concurrency or audit log |
| Key/value store | set, get, delete | prefix scan and ordering | TTL and compare-and-set | transactions or snapshots |
| Crawler | same-host BFS | URL normalization | retries and rate limits | worker fanout and cancellation |
| Scheduler | dependencies and ready queue | cycle detection | retries and deadlines | workers, cancellation, fairness |
| Cache | get/put capacity eviction | update semantics | TTL or LFU | metrics and thread safety |
| Chat or event router | register handlers | route messages | priorities or filters | replay, idempotency, backpressure |
| Log processor | parse records | multiline events | top errors and windows | malformed input and streaming |
| Permission filter | include/exclude resources | groups and inheritance | deny precedence | audit why each item passed |
| Stream assembler | append chunks | sequence ordering | duplicate/missing chunks | timeout and memory cap |
| Experiment splitter | assign users | stable hashing | ramp percentages | sticky overrides and rollback |
State the tests before coding the follow-up. That keeps the interviewer aligned and prevents late rewrites.
| Pattern | Minimum public tests | Private-edge tests to rehearse |
|---|---|---|
| Crawler or graph traversal | start node, duplicate edge, off-host or invalid neighbor | cycles, relative links, malformed URL, empty graph, deterministic order |
| TTL store or rate limiter | set/get, expiry, scan, refill | equality boundary, zero TTL, time moving backward, cleanup during read |
| LRU or LFU cache | capacity eviction, update existing key, get promotion | capacity 0, tie-break by recency, expired entry, stale heap record |
| Scheduler | independent tasks, dependency release, retry | cycle, missing dependency, permanent failure, blocked dependents |
| Ledger | deposit, withdraw, transfer | duplicate idempotency key, conflicting retry, insufficient credits, lock order |
| Parser | one record, multiline continuation, malformed line | orphan continuation, empty input, final flush, very large record |
| Validator | required field, unknown field, nested list | error path, default value, type coercion, repeated failure |
| Stream assembler | in-order chunks, out-of-order chunks | duplicate chunk, missing end marker, timeout, memory cap |
Practice circuit
Don't memorize wording. Learn the patterns. Each linked drill carries a full prompt, clarification questions, sample tests, hidden tests, a solution guide, and validated Python plus Java paths. Use Python first. The Java path is useful when you want maps, classes, and null checks to be explicit, not because labs require Java.
These drills are Python systems work: concurrency, state machines, caches, and ledgers. If a later panel also covers ML coding, reuse earlier lessons on tensor shapes, numerically stable softmax, attention, and training-loop invariants. The same claim-before-mutate discipline applies to buffer writes and gradient steps.
Common pitfalls
- Solving version 1, then adding TTL, transactions, or threads without restating the invariant.
- Using wall-clock sleeps in tests instead of injected time.
- Making crawler output order part of correctness after adding concurrent workers.
- Treating idempotency as "retry the operation" instead of recording the request key and result semantics.
- Adding a global lock around everything without explaining throughput, deadlock, and fairness tradeoffs.
- Stating a free-threaded runtime and then dropping locks on shared mutable state.