Practical coding rounds: key-value store, LRU cache, rate limiter, crawler
Before this lesson: lesson 01 (hash maps and their
average cost), lesson 03 (pointers, breadth-first search).
Python classes, dict, collections.deque, and the basics of asyncio.
Time: about 70 minutes, plus the notebook. Notation: is the number of keys or items;
is a cache capacity.
Why this matters #
Some companies now test "practical" coding instead of short puzzles. A typical format is one project of 60–90 minutes with three or four parts, for example an in-memory database, where each part builds on your code. Other common tasks are an LRU cache, a time-based key-value store, a rate limiter, and a web crawler that starts sequential and must then fetch pages concurrently. Some companies also have a round where you work in a codebase you did not write, sometimes with an AI assistant allowed.
These rounds do not test rare algorithms. They test whether you turn a specification into correct, tested code quickly, and whether your design survives the next requirement. The same building blocks run ML systems: an LLM inference server evicts cached key-value blocks in least-recently-used order, LLM APIs limit clients with token buckets, and pretraining data comes from crawlers.
1. Predict first #
2. The picture #
One class that grows. Part 1 asks for the basic API. Part 2 adds a requirement (expiry, a query, concurrency). Part 3 adds another (transactions, retries, durability). The old tests must still pass. A design that copies all data in part 3, or reads the real clock in part 2, makes the next part slow or impossible to test.
A dict plus one more structure. Choose the state so that every operation is cheap, then write the invariant as a comment at the top of the class.
| Task | State | Invariant | Cost per operation |
|---|---|---|---|
| key-value store | dict + a stack of undo maps |
the dict is the current view; an undo map holds the value before its level first touched each key | on average |
| LRU cache | dict key → node + doubly linked list |
dict and list hold the same keys; list order = order of last use; size | |
| token bucket | per client: tokens and the time of the last update | ||
| crawler | queue (the frontier) + set (seen) |
a URL enters the queue at most once; fetches in flight limit | per URL, plus the fetch |
The working loop for each part (about 15–25 minutes):
- Read the part to the end. Restate the API. Ask about edge cases: a missing key? an overwrite? Does a key expire at expiry or at expiry?
- Choose the state. Write the invariant as a comment.
- Write the smallest code that passes. Use clear names and no clever tricks.
- Run all tests, the old parts too. Add one test for the edge case you asked about.
- Say the cost of each operation before you move on.
The LRU cache as a picture. A least recently used (LRU) cache keeps at most keys. When a new key arrives and the cache is full, it evicts (removes) the key that was used longest ago. The figure shows the two parts of its state: the dict on top points into a list that keeps the keys in order of use.
Press "Next" to apply one operation at a time. The violet node is the one
the last operation touched; it always moves to the most-recently-used end (right). When the size
becomes , the node right after head is evicted (magenta). Turn on
Predict first to guess each result before you see it, and use the examples or edit the operations.
An LRU cache with capacity 3 sees put(a), put(b), put(c), get(a), put(d). Which key does put(d) evict?
After get(a) the order of use, from least to most recent, is b, c, a. put(d) makes the size 4, so the least recently used key b goes. Evicting a is the rule of a FIFO (first in, first out) cache, which ignores the get. A get never removes a key.
3. Definitions and main results #
3.1 Inject time and I/O #
Expiry, rate limits and timeouts depend on time. With the real clock, a test must sleep, so it is
slow and sometimes fails at random. So the constructor takes a clock argument: a function that
returns seconds, by default time.monotonic. A test passes a fake clock and moves it by hand. A crawler
receives its fetch function in the same way. This is dependency injection: the core logic does
not know where time or data come from.
class FakeClock:
"""A clock that a test moves by hand; pass it where code expects time.monotonic."""
def __init__(self, start: float = 0.0) -> None:
self.t = start
def __call__(self) -> float:
return self.t
def advance(self, dt: float) -> None:
self.t += dt
clock = FakeClock()
clock.advance(2.5)
print(clock()) # 2.5
Measure durations with time.monotonic, not time.time. The wall clock (time.time) can jump
backward or forward, for example after a network time correction; the monotonic clock never goes back.
3.2 A key-value store in levels #
-
Level 1: a
dict.get,setanddeletecost on average. -
Expiry (TTL, "time to live"), a common level 2. Store
(value, expires_at). A key is alive whilenow < expires_at. Check expiry lazily: on every read, and delete the key if it has expired. If memory matters, also keep a heap of(expires_at, key)and pop expired entries from time to time. Skip a heap entry whose time no longer matches the stored one: the key was overwritten later. -
Queries.
count(value)in needs a second dict that counts values. Everysetanddeleteupdates it, and so does every rollback (so a rollback must use the same code path).scan(prefix)may sort the matching keys, . -
Nested transactions.
beginstarts a transaction,rollbackundoes it,commitkeeps it, and transactions can be nested.beginmust be , so it must not copy the data. Instead keep a stack of undo maps. The undo map of a level stores, for each key the level has written, the value before this level first touched it, or a markerMISSINGif the key did not exist.- On a write, record the old value only if the key is not yet in the top undo map (first touch).
rollbackwrites the top undo map back into the data (MISSINGmeans: delete the key) and pops it.commitpops the top map and merges it into the parent map with one rule: copy a key into the parent only if the parent has no entry for it. At the outermost level there is no parent: drop the map, because the data already holds the result.
Reads stay , because the data dict is always the current view. A write costs ;
rollbackandcommitcost . No cost depends on the depth.
Why the commit rule is right. The parent's entry for a key is the value from before the parent first touched it. A later rollback of the parent must restore exactly that value. The child's entry is only the value from before the child touched the key, which may already contain the parent's own write. So the parent's entry wins. If the parent has no entry, the parent never wrote the key, so the value before the child is also the value before the parent: copy it up.
Step through the script with "Next". The data dict on the left is always the current view. In the undo stack, orange entries were just recorded (first touch). At a nested commit, aqua entries are copied into the parent and magenta ones are dropped, because the parent already has the key. Switch to commit rule for the shortest case, or to free play and try your own sequence.
The other design: overlays. Keep a stack of overlay dicts that hold the new writes of each
level, with a delete marker. begin is also , and commit merges the top overlay into the one
below. But a read must search the stack from the top down, so a read costs . Say both
designs in an interview, and choose undo maps when reads are frequent.
Durability: an append-only log
To survive a crash, append one line per committed batch to a log file: a single write outside a
transaction, or a whole outermost transaction. Write the line with one write, then call flush and
os.fsync. One line per batch keeps a transaction atomic on disk. On start, replay the log. A last
line without "\n" is a torn write (the process died while writing it): ignore it. To compact the log,
write a snapshot to a temporary file and move it into place with os.replace, which is atomic on
POSIX file systems, so a reader sees either the old file or the new one, never half of it.
3.3 The LRU cache #
State. A dict from key to node, and a doubly linked list of the nodes in order of use. Two
sentinel nodes, head and tail, sit at the ends: the least recently used (LRU) node is right
after head, the most recently used (MRU) node is right before tail. A "use" is a get that finds
the key, or any put.
get(k): find the node in the dict, unlink it, insert it beforetail. .put(k, v): update or create the node and move it beforetail. If the size is now , unlink the node afterheadand delete its key from the dict. .- Why doubly linked: to unlink a node you need its predecessor. A singly linked list must search for it, which costs .
- Why the node stores its key: on eviction you hold the node, and you must delete its key from the dict.
- Why sentinels: the list is never empty, so insert and unlink need no special cases.
class Node:
__slots__ = ("key", "val", "prev", "next")
def __init__(self, key=None, val=None):
self.key, self.val, self.prev, self.next = key, val, None, None
class LRUCache:
"""dict: key -> node; list: head <-> ... <-> tail, LRU next to head, MRU next to tail."""
def __init__(self, capacity: int):
self.cap, self.map = capacity, {}
self.head, self.tail = Node(), Node() # sentinels: the list is never empty
self.head.next, self.tail.prev = self.tail, self.head
def _unlink(self, n):
n.prev.next, n.next.prev = n.next, n.prev
def _push_mru(self, n): # insert just before tail
n.prev, n.next = self.tail.prev, self.tail
self.tail.prev.next = n
self.tail.prev = n
def get(self, key):
n = self.map.get(key)
if n is None:
return None
self._unlink(n); self._push_mru(n) # a hit makes the key most recent
return n.val
def put(self, key, val):
n = self.map.get(key)
if n is not None:
n.val = val; self._unlink(n)
else:
n = self.map[key] = Node(key, val)
self._push_mru(n)
if len(self.map) > self.cap:
lru = self.head.next # least recently used
self._unlink(lru); del self.map[lru.key] # the node must know its key
c = LRUCache(2)
c.put("a", 1); c.put("b", 2); c.get("a"); c.put("c", 3)
print(c.get("b"), c.get("a"), c.get("c")) # None 1 3
In Python, collections.OrderedDict with move_to_end(key) and popitem(last=False) gives the same
behaviour in a few lines. Say so, but expect the interviewer to ask for the hand-written list.
3.4 Rate limiters and the token-bucket bound #
A rate limiter decides, for each request of a client, whether to allow or reject it. A token bucket has a capacity (tokens) and a refill rate (tokens per second). For each client it stores two numbers: the tokens and the time of the last update. A request with cost at time does:
- refill: , then ;
- decide: if , set and allow; otherwise reject and take nothing.
No timer thread is needed: the refill is computed when a request arrives (lazy refill). The capacity is the largest burst; the rate is the long-run limit.
Theorem (burst bound). If every request costs 1 token, a token bucket allows at most requests in any time window .
Proof.
- At time , after the refill and before any request of the window, the bucket holds tokens.
- During the window, the refill adds at most tokens. (The cap at can only remove tokens.)
- Each allowed request removes one token, and never goes below 0.
- So the number of allowed requests is at most .
The bound is reached: with and , send 4 requests at and one at each of . All 9 pass, and for .
Click the time line to add requests, and drag the orange handle to move the window. The blue line is the token level : it rises with slope up to and drops by one for each allowed request; rejected requests are crosses. The panel counts the allowed requests in the window and in the worst window of the same length. Try to beat the bound : you cannot. Bound reached shows a case where the count equals it.
The other common limiters, for a limit of requests per window of length :
| Limiter | State per client | Behaviour |
|---|---|---|
| fixed window | one counter for the current window | cheap; allows up to requests in a short time around a window boundary |
| sliding-window log | timestamps of the last requests | exact; memory per client |
| sliding-window counter | counters of the current and the previous window | estimate previous (part of the previous window still inside) current; close to exact, |
| leaky bucket (as a queue) | a queue drained at a constant rate | smooth output; adds delay |
| token bucket | , | bursts up to , long-run rate |
A token bucket has , tokens/s and starts full. A client sends 6 requests at and 6 more at s. How many of the second group pass?
At , 4 requests pass and . At the refill gives , so 2 pass. The bucket refills to only after s. The refill is computed lazily from the time since the last update, so nothing is "lost" while no request arrives.
A limiter must allow at most 100 requests per minute. Which design can allow 200 requests within one second?
The fixed window allows 100 requests at 0:59.9 and 100 more at 1:00.0, because the counter resets at the boundary. The sliding log looks at the last 60 seconds, so it allows at most 100. The token bucket allows at most in one second.
3.5 A concurrent crawler #
- Part 1, sequential BFS. A queue (
deque) holds the frontier, and a set holds the URLs seen. Normalize a URL before the check: resolve relative links, drop the#fragment, lowercase the scheme and the host. Mark a URL as seen when you discover it, not when you fetch it; otherwise the same URL can enter the queue many times. Follow only links on the start host. - Part 2, concurrency. Fetching waits on the network (it is I/O-bound), so run many fetches at
once with
asyncio(or threads). Limit them with anasyncio.Semaphore, or with worker tasks that read from anasyncio.Queue:await queue.join(), then cancel the workers. - Why the seen check needs no lock in asyncio. The event loop runs one task at a time and switches
tasks only at an
await. With noawaitbetweenif url not in seenandseen.add(url), the pair runs without interruption. With threads it is not safe: another thread can run between the two lines, so use a lock. - Part 3, failures. Catch errors per page, set a timeout per attempt (
asyncio.timeoutin Python 3.11+), retry a few times with backoff (wait longer after each failure), and record the failed URLs. Never catchBaseException: it also catchesasyncio.CancelledError, and then cancellation stops working. One blocking call (time.sleep,requests.get) inside a coroutine stops every task; move it to a thread withasyncio.to_thread.
import asyncio
LINKS = {"/": ["/a", "/b"], "/a": ["/b", "/c"], "/b": ["/a", "/c"], "/c": ["/"]}
async def fake_fetch(url):
await asyncio.sleep(0.01) # pretend network delay
return LINKS.get(url, [])
async def crawl(start, fetch, n_workers=3):
seen, queue, order = {start}, asyncio.Queue(), []
queue.put_nowait(start)
async def worker():
while True:
url = await queue.get()
try:
for link in await fetch(url):
if link not in seen: # no await between check and add:
seen.add(link) # this pair cannot be interrupted
queue.put_nowait(link)
order.append(url)
finally:
queue.task_done()
workers = [asyncio.create_task(worker()) for _ in range(n_workers)]
await queue.join() # every queued URL is done
for w in workers:
w.cancel()
await asyncio.gather(*workers, return_exceptions=True)
return order
print(sorted(asyncio.run(crawl("/", fake_fetch)))) # ['/', '/a', '/b', '/c']
The number of workers is the concurrency limit: at most n_workers fetches are in flight.
A crawler adds a URL to seen only when a worker starts to fetch it (not when it finds the link). Page /c is linked from 50 pages that are fetched before /c. What happens?
Until /c is fetched, it is not in seen, so every page that links to it puts it in the queue again. A check at fetch time can still prevent the repeated fetches, but the queue holds many copies, and in a concurrent crawler two workers can start the same URL. Marking at discovery keeps the invariant "a URL enters the queue at most once".
3.6 Unfamiliar code, with or without an AI assistant #
- Run the tests first. See what passes and what fails before you read anything.
- Find the entry points (
main, the public classes) with a text search, and follow one request from end to end through the files. - Reproduce a bug with a failing test, fix it, and keep the test.
- Make the smallest change that does the job, then run the whole test suite again.
- With an AI assistant: ask narrow questions ("where is X parsed?", "write a test for Y"), read
every line it returns, and run it. Check the edge cases it may skip. The interviewer grades how you
solve the problem and how you verify the result, not your prompts. Never let a tool change the
tests to make them pass; check the diff of the test folder (
git diff tests/) before you accept a change.
Answers to section 1. (1) None, a miss. get(a) made a the most recent key, so put(c) evicted
b. (2) 5 pass at , and the bucket is empty. At it holds
tokens, so 3 pass.
4. Worked example #
Nested transactions, step by step. The store starts as {a: 1}. U1 and U2 are the undo maps of
depth 1 and 2; – means MISSING (the key did not exist).
| Step | Data after the step | Undo stack after the step |
|---|---|---|
begin() |
{a: 1} |
U1 = {} |
set(a, 2), set(b, 5) |
{a: 2, b: 5} |
U1 = {a: 1, b: –} |
begin() |
{a: 2, b: 5} |
U1, U2 = {} |
set(a, 3), delete(b) |
{a: 3} |
U1, U2 = {a: 2, b: 5} |
rollback() |
{a: 2, b: 5} |
U1 = {a: 1, b: –} |
begin(), set(a, 4) |
{a: 4, b: 5} |
U1, U2 = {a: 2} |
commit() |
{a: 4, b: 5} |
U1 = {a: 1, b: –} |
rollback() |
{a: 1} |
empty |
Step by step:
set(a, 2)is the first write toaat depth 1, soU1recordsa: 1.set(b, 5)recordsb: –, becausebdid not exist.- At depth 2,
set(a, 3)records the current valuea: 2inU2, anddelete(b)recordsb: 5. rollback()writesU2back:abecomes 2 andbbecomes 5 again.U2is popped.- A new
U2recordsa: 2forset(a, 4). commit()mergesU2 = {a: 2}intoU1 = {a: 1, b: –}.U1already hasa, so it keepsa: 1and dropsa: 2.- The last
rollback()writesU1back:abecomes 1 andbis deleted. The store is{a: 1}, as before the firstbegin().
Before you read on: if commit() had copied a: 2 into U1, what would the last rollback()
give? It would give {a: 2}: the outer transaction would not undo its own write set(a, 2).
A parent undo map is {a: 1} and the child undo map is {a: 2, c: MISSING}. The child commits. What is the parent map now?
The parent keeps its own, older entry for a, because a rollback of the parent must restore the value from before the parent touched a. The key c is new to the parent, so it takes the child's entry: before both levels, c did not exist. Dropping c is the tempting mistake; then a rollback of the parent would leave c in the data.
The token bucket in numbers (, token/s, full at ):
- : 10 requests. 5 pass; .
- : refill to . Of 3 requests, two pass (, then ) and the third is rejected, because . It could pass after s.
- : refill to . One request passes; .
- Check the theorem: 7 requests passed in , and the bound is . ✓
5. In code #
The Python standard library has real versions of two of these ideas, so you can check the hand work above. SQLite savepoints are nested transactions:
import sqlite3
db = sqlite3.connect(":memory:", isolation_level=None) # we control the transactions
run = db.execute
run("CREATE TABLE kv (k TEXT PRIMARY KEY, v INTEGER)")
run("INSERT INTO kv VALUES ('a', 1)")
def put(k, v): run("INSERT OR REPLACE INTO kv VALUES (?, ?)", (k, v))
def show(): print(dict(run("SELECT k, v FROM kv ORDER BY k")))
run("SAVEPOINT t1"); put("a", 2); put("b", 5); show() # {'a': 2, 'b': 5}
run("SAVEPOINT t2"); put("a", 3); run("DELETE FROM kv WHERE k = 'b'"); show() # {'a': 3}
run("ROLLBACK TO t2"); run("RELEASE t2"); show() # {'a': 2, 'b': 5}
run("SAVEPOINT t2"); put("a", 4); run("RELEASE t2"); show() # {'a': 4, 'b': 5}
run("ROLLBACK TO t1"); run("RELEASE t1"); show() # {'a': 1}
RELEASE is the commit of a savepoint. ROLLBACK TO undoes it but keeps it open, so a RELEASE
follows. functools.lru_cache replays question 1 of section 1: each call is a get, and a miss also
does a put.
from functools import lru_cache
loads = [] # keys that missed and were loaded
@lru_cache(maxsize=2)
def load(key):
loads.append(key)
return key.upper()
for key in ["a", "b", "a", "c", "b"]:
load(key)
print(loads) # ['a', 'b', 'c', 'b']: the second 'b' missed
print(load.cache_info()) # CacheInfo(hits=1, misses=4, maxsize=2, currsize=2)
The notebook asks you to write LRUCache, TokenBucket, the transactional KVStore and crawl
yourself, and tests each one against a simple reference on random inputs.
6. Where it shows up #
- LLM serving. Inference servers such as vLLM cache the key-value blocks of earlier requests (prefix caching). When memory runs out, they evict a block that no running request uses, least recently used first (lesson 13).
- APIs. LLM APIs limit requests and tokens per minute per client. A token bucket is the usual
model: capacity refills continuously instead of resetting at fixed times. A client needs retries with
backoff and should respect the
retry-afterheader of a rejected request. - Data pipelines. Pretraining corpora come from crawlers: URL normalization, deduplication, a rate limiter per host (to be polite to the site), and retries.
- ML for science and HPC. Climate and seismic data portals limit requests per user, so a download
script needs a limiter and retries. Expensive, deterministic simulator calls are cached with
lru_cache. A training checkpoint is written to a temporary file and moved into place withos.replace, so a job that the scheduler kills never leaves a half-written checkpoint (the same idea as the log compaction in 3.2).
7. Common mistakes #
- A design that blocks the next part. Copying the whole dict in
begin( per transaction), or reading the global clock instead of an injected one. - Not running the old tests after a new part.
- LRU. A
getthat does not update the order of use; a node without its key; a singly linked list; the insertion order of a plain dict used as the order of use, without moving the key on every use. - Token bucket. A refill without the cap ; taking tokens from a rejected request;
time.time()instead oftime.monotonic(). - Transactions. A commit that overwrites the parent's entries; a rollback that does not update a value counter.
- Crawler. Marking URLs as seen at fetch time; no normalization (
/aand/a#topfetched twice); no concurrency limit;except BaseException; a blocking call inside a coroutine. - AI assistant. Accepting code without running it; letting it edit the tests.
- Silent errors. Returning
Nonefor a bad input without saying so. Decide early whether the API raises an exception or returns a value, and write it in the docstring.
8. Check yourself #
- Why does an LRU cache use a doubly linked list, and why does each node store its key?
Answer
Unlinking a node in needs its predecessor, which a singly linked list can only find by a scan. On eviction you hold the LRU node and must delete its key from the dict, so the node must know its key. - A token bucket has , tokens/s and starts full. What is the largest number of
unit-cost requests it can allow in s? Give a sequence that reaches it.
Answer
. It is reached by 10 requests at , then 2 requests at each of . - A fixed-window limiter allows 100 requests per minute, and the windows start at whole minutes. How
many requests can it allow within one interval of 60 seconds?
Answer
200: 100 at the end of one window (say at 0:59.9) and 100 at the start of the next (1:00.0), so 200 within 0.1 s. - In an asyncio crawler, two tasks find the same URL at almost the same time. Why is
if url not in seen: seen.add(url); queue.put_nowait(url)safe? When is it not safe?Answer
The event loop switches tasks only at an `await`, and there is none between the check and the add, so no other task runs in between. It is not safe with threads, or if an `await` that can suspend sits between the check and the add (for example `await fetch(url)`, or `await queue.put(url)` on a full bounded queue, before `seen.add(url)`). - Your store has
count(value)in with a counter dict. Afterbegin,set(a, 7),rollback,count(7)returns 1, butano longer has the value 7. What is the bug?Answer
The rollback writes the old values straight into the data dict and does not update the counter. Make rollback use the same internal `set`/`delete` path as the public methods (without recording undo entries), so the counter stays equal to the counts of the values in the data. - An AI assistant returns a patch that makes all failing tests pass. What do you check before you
accept it?
Answer
That the diff does not touch or weaken the tests; that you read and understand every changed line; that the whole suite passes again; that a test reproduces the original bug; and the edge cases it may have skipped (empty input, errors, limits).
What is the state of an LRU cache, and what do
getandputcost?A dict from key to node and a doubly linked list of the nodes in order of use (with sentinels). Both operations cost .
Why does an LRU cache need a doubly linked list?
Unlinking a node in needs its predecessor; a singly linked list must scan to find it.
Why does each LRU node store its key?
On eviction you hold the least recently used node and must delete its key from the dict.
Write the token-bucket update for a request at time .
, ; if , then and allow, else reject.
How many unit-cost requests can a token bucket allow in a window of length ?
At most : at most tokens at the start of the window, plus at most refilled tokens.
What is the weakness of a fixed-window rate limiter with limit ?
It can allow up to requests in a short time: at the end of one window and at the start of the next.
What does the undo map of a transaction level store?
For each key the level has written, the value before this level first touched it, or
MISSINGif the key did not exist.What is the commit rule for nested undo maps?
Copy a key of the child map into the parent only if the parent has no entry for it; at the outermost level, drop the map.
Why inject the clock into a class with expiry or rate limits?
Tests can move a fake clock by hand instead of sleeping, so they are fast and deterministic. The default is
time.monotonic, which never goes back.Why does
if url not in seen: seen.add(url)need no lock in asyncio, but a lock with threads?asyncio switches tasks only at an
await, and there is none between the two lines. Threads can switch between any two lines.
9. Interview angle #
Which structure for which wording.
| The problem says … | Try … |
|---|---|
| "evict the least recently used", "cache with capacity" | dict + doubly linked list (or OrderedDict) |
| "at most N requests per …", "allow bursts" | token bucket per client, with an injected clock |
| "expires after t seconds" | (value, expires_at) with a lazy check; a heap of expiry times if memory matters |
| "begin / commit / rollback", "undo" | stack of undo maps (or overlays) |
| "value of key at timestamp t" | dict of lists sorted by time, then bisect |
| "count keys with value v" | a second dict that counts values, updated on every write |
| "visit every page once", "in parallel" | BFS + seen set (mark at discovery) + semaphore or worker tasks |
| "must survive a crash" | append-only log, fsync per commit, replay on start |
The routine to say aloud for each part. "Let me restate the API … I will ask two edge cases … The state is … and the invariant is … Here is the smallest version … I run all tests, the old ones too … Each operation costs …"
Typical questions and short model answers.
- "Make the LRU cache thread-safe." Put one lock around every method,
gettoo, becausegetchanges the order. If there is too much contention, shard the cache by a hash of the key, with one lock and one list per shard. The cost is that the LRU order is then only per shard. - "Run the rate limiter on 50 servers." Either keep one bucket per key in a shared store with an atomic update (for example a Redis script that does refill and decide in one step), or give each server a local bucket with rate . The shared store is exact but adds latency and a central point of failure; local buckets are fast but inexact when the load is uneven.
- "The crawler must handle URLs." The seen set no longer fits in memory. Store 8-byte hashes of normalized URLs, or use a Bloom filter (a false positive skips a page; there are no false negatives). Partition the URLs by host over machines, so each host has one rate limiter.
- "Make the store survive a crash." Append-only log,
fsyncper commit, replay on start, ignore a torn last line, compact with a snapshot andos.replace. - "Add a TTL to the LRU cache." Store the expiry time in the node; a
getof an expired key deletes it and returns a miss. Eviction order stays LRU; for "expire first", keep a heap of expiry times with lazy deletion.
Typical follow-ups. What happens at capacity 0 or 1? Which exception does commit raise without
an open transaction? Is a key alive at exactly expires_at? What is the memory per client of each
limiter? How do you test the concurrent crawler (a fake fetch that records how many calls are in
flight)?
10. Practice #
The notebook for this lesson runs in Colab. Each exercise has a check cell and a folded solution:
LRUCachewith a dict and a doubly linked list, tested against a slow list-based model on random operations.TokenBucketwith an injected fake clock, tested on the worked example and on the bound for random arrival times.KVStorewith nested transactions (undo maps), tested against a copy-based reference on random operation sequences.crawl: a sequential BFS with an injectedfetchon a small in-memory link graph, with URL normalization; the concurrent version is an optional extension.
Timed practice (25 minutes each), problem names from public lists: LRU Cache (146), LFU Cache (460), Time Based Key-Value Store (981), Design Hit Counter (362), Logger Rate Limiter (359), Web Crawler Multithreaded (1242), Snapshot Array (1146).
Say it aloud (3 minutes): explain to an imaginary interviewer the commit rule for nested undo
maps, with the example parent {a: 1} and child {a: 2, c: MISSING}, and why the parent's entry wins.
11. Watch and read #
- Stripe engineering blog, Scaling your API with rate limiters: the token bucket in production, the concurrent-request limiter and load shedding.
- Cloudflare blog, How we built rate limiting capable of scaling to millions of domains: the sliding-window counter and its error.
- Python documentation:
asyncioqueues (with a worker-pool example),asynciosynchronization primitives (Semaphore,Lock),collections.OrderedDict,functools.lru_cacheandtime.monotonic. - SQLite documentation, SAVEPOINT: nested transactions
with
SAVEPOINT,RELEASEandROLLBACK TO.
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.