Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
After the system design capstones, the AI Lab Interviewing section turns architecture knowledge into interview execution. The first test is whether you can build small, correct systems while requirements change.
AI lab coding formats vary by team. Public frontier-lab guidance emphasizes well-designed solutions, tests, live coding, debugging, standard-library fluency, experience, motivation, communication, and tradeoff analysis.[1][2] Practice that bar with one base prompt followed by staged requirements: add TTLs, add concurrency, add cancellation, add rate limits, preserve deterministic output, or explain why your state model won't corrupt itself.
The operating model
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.
Good interview code isn't the most abstract code. It's code whose invariants can be defended while requirements change.
What is the common failure mode in staged coding rounds?
Answer
Losing the state model. Candidates often solve version 1, then bolt on TTLs, transactions, or concurrency in a way that makes ownership unclear. Strong answers keep data 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.
Prompt bank
Use these as drills. Don't memorize wording. Learn the patterns.
| Prompt pattern | Base implementation | Follow-ups |
|---|---|---|
| Same-host web crawler | BFS/DFS with visited set | concurrency, per-host rate limit, timeouts, cancellation |
| In-memory key/value DB | set, get, delete, scan | TTL, compare-and-set, transactions, snapshots |
| Banking ledger | accounts, deposit, withdraw, transfer | idempotency, reversals, scheduled transfers, deadlock avoidance |
| Task scheduler | dependency graph and ready queue | cycle detection, retries, worker pool, cancellation |
| Log parser | multiline event grouping | malformed lines, rolling windows, top errors |
| Rate limiter | fixed or sliding window | token bucket, multi-dimensional quotas, retry-after |
| LRU/TTL cache | capacity eviction | TTL, thread safety, metrics, stale cleanup |
| LFU/cache policy | frequency buckets and recency tie-break | TTL, capacity 0, update semantics, thread safety |
| Stream assembler | chunks, sequence IDs, end markers | out-of-order chunks, duplicate chunks, timeout, memory cap |
| Schema validator | nested dict/list validation | defaults, unknown fields, coercion, error paths |
| Batch scheduler | queue by arrival and priority | fairness, deadlines, retries, starvation |
| Secret redactor | token scanning and replacement | overlapping matches, allowlists, multiline logs |
Frontier live-practice set
Use this as the main circuit. Each linked drill carries a full prompt, clarification questions, sample tests, hidden tests, solution guide, and validated Python plus Java paths. Run each problem twice: first in Python for speed, then in Java to force explicit classes, maps, and boundary checks.
Question pattern taxonomy
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 |
Levelled prompt ladder
Many frontier coding rounds feel like one product-shaped problem that grows across levels. Practice each ladder by shipping level 1 quickly, then preserving the same invariant as requirements change.
| Prompt family | Level 1 | Level 2 | Level 3 | Level 4 |
|---|---|---|---|---|
| GPU quota reservation system | 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 |
Use this timing target:
| 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 next follow-up design, race risk, and production hardening |
Pattern testcase checklist
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 funds, 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 |
Debugging protocol
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.
Common debug pivots:
| 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 |
Strong candidates aren't bug-free. They recover fast because they can name which invariant is broken.
Follow-up triage map
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 |
Good follow-up handling sounds calm because the state boundary is still visible.
Mock coding prompts
Use these as timed practice. Read the prompt, write a small solution and tests, then open the solution guide.
Prompt 1: same-host crawler
You're given a starting URL and a function get_links(url) -> list[str]. Implement a crawler that visits only URLs on the same host as the start URL.
Requirements:
- Return visited URLs in deterministic breadth-first order for the single-threaded version.
- Don't visit the same URL twice.
- Ignore malformed URLs and off-host URLs.
- Follow-up: add worker fanout without corrupting
visited. - Follow-up: add a per-host rate limit and cancellation event.
Clarifying questions to ask:
- 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?
Solution guide
Use a FIFO work queue for the base version because the prompt asks for breadth-first order. Put URL normalization and host checks before enqueueing. The invariant is: a URL enters the queue only after it has been accepted into visited.
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
37print(crawl("https://repo.example/start", lambda url: PAGES.get(url, [])))1['https://repo.example/start', 'https://repo.example/tests', 'https://repo.example/ci']Follow-up guide
For worker fanout, say explicitly that deterministic return order becomes expensive unless the prompt still requires it. Protect the claim step, not the whole fetch:
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/tests"))
14print(claim("https://repo.example/tests"))
15print(sorted(visited))1True
2False
3['https://repo.example/start', 'https://repo.example/tests']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. The full answer should name all three behaviors: duplicate prevention, bounded fetch concurrency, and graceful stop.
Prompt 2: TTL key/value store
Implement an in-memory store with set(key, value, ttl=None), get(key), delete(key), and scan(prefix).
Requirements:
ttlis measured in seconds.- Expired keys behave as missing.
- Tests must not call
sleep. - Follow-up: add compare-and-set.
- Follow-up: add thread safety.
Clarifying questions to 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?
Solution guide
Inject now so tests can advance time directly. Store expires_at beside each value. The invariant is: every public read path either returns a non-expired value or removes the expired key.
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(key for key in list(self.items) if key.startswith(prefix) and self.get(key) is not None)
32
33clock = {"now": 10.0}
34store = Store(lambda: clock["now"])
35store.set("task:1", "running", ttl=5.0)
36store.set("task:2", "ready")
37print(store.get("task:1"), store.scan("task:"))
38clock["now"] = 15.0
39print(store.get("task:1"), store.scan("task:"))1running ['task:1', 'task:2']
2None ['task:2']Follow-up guide
For compare-and-set, 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. The important answer is atomicity: no other thread can change the key between compare and set.
Prompt 3: task scheduler with retries
Implement a scheduler for tasks with dependencies. A task becomes runnable when all dependencies have completed.
Requirements:
- Detect dependency cycles before running.
- Run ready tasks in priority order.
- Retry failed tasks up to
max_attempts. - Return completed tasks, permanently failed tasks, and blocked dependents separately.
- Follow-up: add worker fanout.
Clarifying questions to ask:
- Does a lower priority number run first, or does a higher number run first?
- If a dependency permanently fails, should dependents be marked failed, skipped, or blocked?
- Are retries immediate, delayed, or scheduled with backoff?
Solution guide
Represent the graph explicitly: dependents[task] points to 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.
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("pack", priority=2),
69 Task("ship", priority=3, deps=("pack",)),
70 Task("label", priority=1),
71]
72fail_once = {"pack"}
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("pack", 1), Task("ship", 2, ("pack",))], lambda _: False, max_attempts=1))1(['label', 'pack', 'ship'], [], [])
2([], ['pack'], ['ship'])Follow-up guide
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.
For 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/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."
Drill 1: token bucket with deterministic time
Rate limiters show up because they combine state, boundary conditions, and production behavior. Retry policies often need jitter to avoid synchronized retry storms, so this prompt is really about overload control as much as counters.[3] A strong implementation injects time so tests don't sleep.
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 capacityImplementation notes:
- The key can be a user, organization, endpoint, or model.
nowis injected for deterministic tests.- Impossible costs fail immediately instead of returning retry advice that can never succeed.
- 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
_bucketsand bucket mutation.
Drill 2: ledger with idempotency
Ledger prompts test whether you can keep money-like state consistent. Use append-only events when possible; if you maintain balances, update balance and 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 funds")
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", "acct", 100))
33print(ledger.apply("withdraw-1", "acct", -30))
34print(ledger.apply("deposit-1", "acct", 100))
35print(ledger.balance["acct"], len(ledger.events))
36try:
37 ledger.apply("deposit-1", "acct", 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 account changed later. Reusing a key for a different operation fails loudly instead of silently returning an unrelated balance.
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 = {"acct-a": Lock(), "acct-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("acct-b", "acct-a"):
13 print("locked:", sorted({"acct-b", "acct-a"}))1locked: ['acct-a', 'acct-b']Drill 3: multiline log parser
Log parsers test string handling and explicit malformed-input policy. 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]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.
Failed fetch vs claim timing
The base crawler claims into visited before fetch so two workers never race the same URL. A hard network failure then can't retry the same URL unless you model more states:
| 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: claim still happens under the lock (invariant), but success and permanent failure are different outcomes. A production sketch keeps visited for terminal success, in_flight for active work, and a failed or retry queue with attempt counts so transient errors don't become permanent silence.
Concurrency model signals
Before you reach for threads or async, name what kind of work you're speeding up. This one sentence is a strong interview 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 change that constraint, so state which runtime you are discussing.[4] Free-threading is not data-race freedom: shared mutable state still needs locks, atomics, or other synchronization. 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 great 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. That's the async trap, made concrete.
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.
Concurrency contract
Define these before adding workers:
- 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.
If your loop includes ML coding
This section is Python systems work: concurrency, state machines, caches, and ledgers. Frontier research-engineer loops often mix systems with tensor drills. If the panel also covers ML coding, rehearse curriculum material on tensor shapes, numerically stable softmax, attention, and training-loop invariants before the onsite. 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 free-threaded runtime and then dropping locks on shared mutable state.