Shoring Up Weak Points + a Coding Warm-Up: Rate Limiter, LRU, Concurrency Control, Streaming JSON Parsing
Do targeted work on the weak points listed on day 28, while warming up on four frequently asked coding problems: a rate limiter, an LRU cache, concurrency control, and streaming JSON parsing.
Today's Goals
- Work through day 28's weak-point list item by item and record the improvement
- Independently implement a rate limiter and an LRU cache
- Implement a simple concurrency controller and a streaming JSON parser
Yesterday's two mock interviews produced not "it felt fine" but a four-column weak-point list. Today learns nothing new and does two things: work that list item by item, and drill the four coding problems most likely to appear on a whiteboard until you can write them from memory.
Plain-Language Walkthrough
It is always the same two bars: practice them slowly in isolation, not from the top
Anybody who has learned an instrument knows this scene: two weeks into a piece, the mistake is always the key change in bars 17 to 18. So you play the whole thing ten more times — the piece sounds smoother and those two bars are still wrong.
The reason is plain: ten runs from the top means ten more repetitions of the parts already right and ten more of the part that is wrong. Muscle memory does not distinguish right from wrong, it counts repetitions. What works is isolating those two bars, halving the tempo, watching your fingers for 20 repetitions, and only then putting them back into the piece. That is slow sectional practice.
A vague review is playing the whole thing ten more times. Paging through thirty days of notes from D1 to D28 gives you a reassuring sense of having covered it, and tomorrow you will still stumble where you stumbled yesterday. So today's work must land on yesterday's list.
The list keeps its four columns, and do not rename them — symptom, root cause, smallest practice action, how to verify. Here is a row you very likely wrote yesterday:
| Symptom | Root cause | Smallest practice action | How to verify |
|---|---|---|---|
| Eight minutes into describing mini-koda without saying what I did | told the development chronologically with no conclusion first | write a 90-second project opening — one line of positioning, one on the hardest point, one on the result — and rehearse it | time a recording; finish within 90 seconds with no notes |
Why that row passes: the symptom is observable (that figure of 8 minutes), the root cause points at one concrete habit, the action is small enough (a 90-second passage, not "practice communication"), and the verification yields a yes or no on the spot. The third column is the easiest to water down — "practice the project walkthrough a few more times" is not a smallest action, and "write a 90-second opening and rehearse it" is. Slowing down and isolating both live in that column.
The order matters too: fix what one repetition can improve before what needs accumulated time. Stumbling in the self-introduction, a missing R in STAR, and drawing before clarifying in system design can all be fixed today; "rusty on algorithms" only yields to actually typing them out this afternoon.
So why exactly a rate limiter, LRU, concurrency control, and streaming JSON parsing? Because they were not drawn at random from a problem site — those four happen to be the archetypes of what you built over these thirty days, each connected to a production scenario you already understand. More importantly, standard answers are everywhere online, and what separates candidates was never the code. So the four sections below cover four things each: what the interviewer is really testing, the complexity you should volunteer, three edge cases, and the alternative implementation you gave up.
Rate limiter: four algorithms, and implement the token bucket
What the interviewer is really testing: not whether you can write a counter, but whether you know that four semantically different algorithms hide under "rate limiting," and whether you volunteer where each one embarrasses itself.
The four, simplest first:
| Algorithm | In a sentence | Memory | Fatal weakness |
|---|---|---|---|
| Fixed window counter | one counter per minute, cleared at the boundary | one integer | double at the boundary |
| Sliding window log | store every request's timestamp and count the last minute | proportional to request count | memory cannot take high frequency |
| Sliding window counter | weight the previous window's count proportionally to approximate sliding | two integers | approximate, slightly off under bursts |
| Token bucket | tokens added at a steady rate, one taken per request | two numbers | needs one timestamp |
The boundary doubling must be mentioned, as it is this question's classic follow-up. Limiting 100 per minute, a user maxes out 100 at 12:00:59, the clock ticks to 12:01:00 and the counter clears, and they can immediately do 100 more — those 2 seconds across the boundary actually admitted 200, which is enough to break a database or a model API downstream. The sliding window counter exists to fix exactly that.
Implement the token bucket, because it constrains long-run rate and instantaneous burst at once: capacity 100 refilling 10 a second averages 10 a second and, once saved up, permits a burst of 100 — which is what real traffic looks like, with two dials for two business constraints.
The key is lazy refill: do not run a timer adding tokens every second, because a hundred thousand users is a hundred thousand timers and the scheduling alone crushes the machine. Compute on take, refilling by however long since the last operation.
// Token bucket: capacity, refilled steadily at refillPerSec per second.
// Lazy refill - no timer; on take, refill by however long since the last refill.
// now is passed in by the caller (milliseconds) so tests can fast-forward without sleeping.
class TokenBucket {
constructor(capacity, refillPerSec, nowMs) {
this.capacity = capacity
this.refillPerSec = refillPerSec
this.tokens = capacity // a cold start is a full bucket: one burst of capacity is allowed
this.lastRefillMs = nowMs
}
tryAcquire(cost, nowMs) {
const elapsedMs = Math.max(0, nowMs - this.lastRefillMs) // a clock going back neither deducts nor gifts
this.tokens = Math.min(this.capacity, this.tokens + (elapsedMs / 1000) * this.refillPerSec)
this.lastRefillMs = nowMs
if (this.tokens < cost) return false
this.tokens -= cost
return true
}
}import time
from dataclasses import dataclass, field
# Units are seconds, using time.monotonic's monotonic clock -
# unaffected by system clock changes, which beats basing it on time.time.
@dataclass
class TokenBucket:
capacity: float
refill_per_sec: float
last_refill: float = field(default_factory=time.monotonic)
tokens: float = field(init=False)
def __post_init__(self) -> None:
self.tokens = self.capacity # a cold start is a full bucket
def try_acquire(self, cost: float, now: float) -> bool:
elapsed = max(0.0, now - self.last_refill)
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_sec)
self.last_refill = now
if self.tokens < cost:
return False
self.tokens -= cost
return True// synchronized is mandatory in the single-machine version: refill and deduct must sit in
// one critical section, or two threads both read "one left" and both pass.
// Units are milliseconds.
final class TokenBucket {
private final double capacity;
private final double refillPerSec;
private double tokens;
private long lastRefillMs;
TokenBucket(double capacity, double refillPerSec, long nowMs) {
this.capacity = capacity;
this.refillPerSec = refillPerSec;
this.tokens = capacity; // a cold start is a full bucket
this.lastRefillMs = nowMs;
}
synchronized boolean tryAcquire(double cost, long nowMs) {
long elapsedMs = Math.max(0L, nowMs - lastRefillMs);
tokens = Math.min(capacity, tokens + elapsedMs / 1000.0 * refillPerSec);
lastRefillMs = nowMs;
if (tokens < cost) return false;
tokens -= cost;
return true;
}
}// Units are seconds. Wrap it in an actor or a lock when shared across tasks -
// refill plus deduct must be one indivisible action, which holds in all four languages.
final class TokenBucket {
private let capacity: Double
private let refillPerSec: Double
private var tokens: Double
private var lastRefill: Double
init(capacity: Double, refillPerSec: Double, now: Double) {
self.capacity = capacity
self.refillPerSec = refillPerSec
self.tokens = capacity // a cold start is a full bucket
self.lastRefill = now
}
func tryAcquire(cost: Double, now: Double) -> Bool {
let elapsed = max(0, now - lastRefill)
tokens = min(capacity, tokens + elapsed * refillPerSec)
lastRefill = now
guard tokens >= cost else { return false }
tokens -= cost
return true
}
}The complexity to volunteer: one decision is constant time and each key stores two numbers (the token balance and the last refill instant), so space is constant — which is its biggest advantage over the sliding window log.
Three edge cases: a cold start's bucket is full, so the first wave admits capacity and the capacity-plus-first is refused; repeated calls within the same millisecond have zero elapsed time and must not conjure tokens; and coming back after ten idle minutes, the refill must be capped by capacity rather than accumulating six thousand tokens and killing the downstream. The third is the most often missed, and that Math.min line is what does it. A fourth is the clock going backwards, where Math.max(0, ...) neither deducts nor gifts.
The alternative given up: the sliding window log. It is the most precise, and it is given up on memory — limiting 1,000 per minute with a hundred thousand active users stores up to a hundred million timestamps in the worst case. Saying "more precise, and memory proportional to request volume" out loud is worth far more than silently writing a token bucket.
-- token_bucket.lua: refill and deduct must complete in one EVAL
-- KEYS[1] = the bucket's key; ARGV = capacity, refill per second, current ms, cost
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local last = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - last) / 1000 * refill)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- The time for an empty bucket to fill plus a margin, so long-idle keys expire themselves
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refill * 1000) + 1000)
return allowedThe timestamp is passed in rather than read inside the script to keep the script deterministic, at the cost of instances' clocks needing rough alignment. That trade-off is worth volunteering, being distributed rate limiting's second most common follow-up.
LRU cache: this one really tests which language you are writing
What the interviewer is really testing: on the surface a hash map plus a doubly linked list, and really two layers. The first is whether you know why it must be those two structures combined — the hash map gives constant-time lookup by key and the doubly linked list gives constant-time "unlink and reattach at the tail," and missing either degrades into a linear scan. The second is subtler: whether what you wrote in this language looks like this language.
This problem happens to be where the four languages differ most: JavaScript's Map preserves insertion order, so delete then set promotes a key and the list can be skipped; Python's OrderedDict has move_to_end and popitem(last=False), done in two lines; Java goes further, with LinkedHashMap's constructor taking accessOrder as true for access order and one override of removeEldestEntry for eviction; Swift's standard library has no order-preserving dictionary, so you write it by hand.
// JS's Map preserves insertion order, so "promote" is delete then set and no list is needed.
class LRUCache {
constructor(capacity) {
if (capacity <= 0) throw new Error('capacity must be greater than 0')
this.capacity = capacity
this.map = new Map()
}
get(key) {
if (!this.map.has(key)) return undefined
const value = this.map.get(key)
this.map.delete(key)
this.map.set(key, value) // reinserted at the tail = most recently used
return value
}
put(key, value) {
if (this.map.has(key)) this.map.delete(key) // updating an existing key also refreshes order
this.map.set(key, value)
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value // the head = least recently used
this.map.delete(oldest)
}
}
}from collections import OrderedDict
class LRUCache:
"""Python has OrderedDict.move_to_end, half the length of a hand-written list.
For a real business cache use functools.lru_cache; writing it by hand is because
an interview wants to see the eviction order."""
def __init__(self, capacity: int) -> None:
if capacity <= 0:
raise ValueError("capacity must be greater than 0")
self.capacity = capacity
self.store: OrderedDict[str, int] = OrderedDict()
def get(self, key: str) -> int | None:
if key not in self.store:
return None
self.store.move_to_end(key) # moved to the tail = most recently used
return self.store[key]
def put(self, key: str, value: int) -> None:
if key in self.store:
self.store.move_to_end(key) # updating an existing key also refreshes order
self.store[key] = value
if len(self.store) > self.capacity:
self.store.popitem(last=False) # pop the head = least recently usedimport java.util.LinkedHashMap;
import java.util.Map;
// The JDK has this built in: accessOrder true gives LRU order, and removeEldestEntry is
// called back after each insertion, evicting the head when it returns true.
final class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
LRUCache(int capacity) {
super(16, 0.75f, true); // true = access order, so get also moves an entry to the tail
if (capacity <= 0) throw new IllegalArgumentException("capacity must be greater than 0");
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}// Swift's standard library has no order-preserving dictionary, so it is a hash map plus a
// doubly linked list by hand. Which makes this the closest to what an interviewer wants
// when they say "no built-in ordered containers."
final class LRUCache {
private final class Node {
let key: String
var value: Int
var next: Node?
weak var prev: Node? // prev must be weak, or mutual references form a cycle and never free
init(key: String, value: Int) {
self.key = key
self.value = value
}
}
private let capacity: Int
private var map: [String: Node] = [:]
private let head = Node(key: "", value: 0) // sentinels, sparing half the nil checks
private let tail = Node(key: "", value: 0)
init(capacity: Int) {
precondition(capacity > 0, "capacity must be greater than 0")
self.capacity = capacity
head.next = tail
tail.prev = head
}
func get(_ key: String) -> Int? {
guard let node = map[key] else { return nil }
moveToTail(node) // a get counts as a use
return node.value
}
func put(_ key: String, _ value: Int) {
if let node = map[key] {
node.value = value
moveToTail(node)
return
}
let node = Node(key: key, value: value)
map[key] = node
insertBeforeTail(node)
// Insert before evicting; reversed, a capacity of 1 evicts what you just added
if map.count > capacity, let oldest = head.next, oldest !== tail {
unlink(oldest)
map.removeValue(forKey: oldest.key)
}
}
private func unlink(_ node: Node) {
node.prev?.next = node.next
node.next?.prev = node.prev
node.next = nil
node.prev = nil
}
private func insertBeforeTail(_ node: Node) {
let last = tail.prev
last?.next = node
node.prev = last
node.next = tail
tail.prev = node
}
private func moveToTail(_ node: Node) {
unlink(node)
insertBeforeTail(node)
}
}See the difference: the same problem is 20 lines in JS and Python, 15 in Java, and 60 in Swift. That is not about strength, it is about where each standard library drew its boundary. In an interview, write the idiom of whichever language you use; transplanting Swift's hand-written list into JS makes the interviewer conclude you do not know Map.
But prepare a counter: the interviewer will very likely follow up with "no built-in ordered containers." Then the Swift version above is the standard answer's shape — sentinel head and tail nodes, unlink and insertBeforeTail as two private methods, and moveToTail composed from them. The hand-written version's structure is the same in all four languages, differing only in memory management: Swift declares prev weak against reference cycles, while Java and JS leave it to garbage collection.
The complexity to volunteer: get and put are both constant time (a hash lookup plus pointer rewrites), and space is proportional to capacity. Point out that constant time is amortized — a hash-map resize is linear. That sentence tells the interviewer you are not reciting a conclusion.
Three edge cases: a get hit must also refresh order (the most common error refreshes only in put, so hot data gets evicted); a put on an existing key must refresh order as well as the value, rather than inserting as new and miscounting capacity; and with capacity 1, two consecutive puts must insert before evicting, since reversing it evicts what you just added.
The alternative given up: one hash map alone, each value carrying a last-access timestamp, scanning the whole table for the minimum on eviction. It can be submitted in five minutes, and eviction is linear — the bigger the cache the slower, and a cache exists to be fast. Saying "I know that version and gave it up because eviction is linear" proves you were choosing better than getting it right silently does.
Concurrency control: Promise.all does not control concurrency at all
What the interviewer is really testing: this one is practically made for agent roles. Your agent calls 20 tools in parallel, embeds 500 documents in a batch, runs 8 subtasks at once — release all of them and you either hit the downstream's rate limit or exhaust memory. So what the interviewer wants confirmed is: can you distinguish "await a batch of tasks" from "limit how many run at once."
The most typical wrong answer maps 500 tasks into promises and calls Promise.all. That code's concurrency is 500 — Promise.all only awaits their completion, and a promise's request went out the moment it was created. The same misconception is "control concurrency with asyncio.gather" in Python and "control concurrency with CompletableFuture.allOf" in Java.
There are two correct shapes: a fixed number of workers pulling from one task queue (JS's idiom), or a semaphore standing in front of task launch (Python's and Java's idiom); Swift opens a sliding window with a TaskGroup.
// Limit simultaneously running async tasks to limit.
async function mapWithLimit(items, limit, worker) {
const results = new Array(items.length)
let cursor = 0
// One runner is one concurrency slot, and the slot count is fixed at limit
async function runner() {
while (cursor < items.length) {
const index = cursor++
try {
results[index] = { ok: true, value: await worker(items[index]) }
} catch (error) {
// A failure must return its slot: without catching here the runner exits and
// that slot stays empty forever, quietly dropping concurrency below limit.
results[index] = { ok: false, reason: String(error) }
}
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner))
return results
}import asyncio
from typing import Awaitable, Callable, TypeVar
T = TypeVar("T")
async def map_with_limit(
items: list[T], limit: int, worker: Callable[[T], Awaitable[object]]
) -> list[dict[str, object]]:
sem = asyncio.Semaphore(limit)
async def run(item: T) -> dict[str, object]:
# async with always releases on leaving scope, equivalent to try/finally, so a
# raising task cannot leak a slot - the most reassuring part of the Python version.
async with sem:
try:
return {"ok": True, "value": await worker(item)}
except Exception as exc:
return {"ok": False, "reason": str(exc)}
# gather only collects results; the semaphore above controls concurrency, not gather
return list(await asyncio.gather(*(run(item) for item in items)))// Dependencies: java.util.concurrent (JDK 17+)
// acquire before submitting: the main thread is blocked first, so the task queue cannot
// grow unbounded, which is natural backpressure.
static <T, R> List<String> mapWithLimit(List<T> items, int limit, Function<T, R> worker)
throws InterruptedException {
var sem = new Semaphore(limit);
var pool = Executors.newCachedThreadPool();
var futures = new ArrayList<CompletableFuture<String>>();
try {
for (T item : items) {
sem.acquire();
futures.add(CompletableFuture.supplyAsync(() -> {
try {
return "ok:" + worker.apply(item);
} catch (RuntimeException e) {
return "err:" + e.getMessage();
} finally {
sem.release(); // returned on success and failure; this line is the crux
}
}, pool));
}
return futures.stream().map(CompletableFuture::join).toList();
} finally {
pool.shutdown();
}
}// withTaskGroup runs everything added via addTask concurrently and limits nothing itself.
// Concurrency needs your own window: fill limit slots, then add one per result received.
func mapWithLimit<T: Sendable>(
_ items: [T],
limit: Int,
worker: @escaping @Sendable (T) async throws -> String
) async -> [String] {
var results = [String](repeating: "", count: items.count)
await withTaskGroup(of: (Int, String).self) { group in
var next = 0
func submit(_ index: Int) {
let item = items[index]
group.addTask {
// Errors must collapse into result values inside the task: letting one
// escape cancels the whole group, losing the remaining tasks' slots and results.
do { return (index, "ok:" + (try await worker(item))) } catch {
return (index, "err:\(error)")
}
}
}
while next < min(limit, items.count) {
submit(next)
next += 1
}
for await (index, value) in group {
results[index] = value
if next < items.count {
submit(next)
next += 1
}
}
}
return results
}The four versions put "return the slot" in different places and say the same thing: returning a slot must happen on every exit path. Java puts it in a finally, Python gets it automatically from async with, Swift collapses errors into result values inside the task (or the whole group is cancelled), and JS relies on the try and catch inside the while loop — once an exception escapes a runner, that runner exits and one slot is lost forever. Code missing that step behaves perfectly on the happy path and only slows down until it stalls once the downstream starts erroring, which is the hardest kind of bug to find.
The complexity to volunteer: total time is roughly task count over concurrency times per-task duration; memory resident at once is proportional to concurrency rather than to task count — that second half is the real benefit.
Three edge cases: an empty task list must not wait forever; a concurrency limit above the task count should not spawn surplus runners (that Math.min(limit, items.length) line handles both); and when one task throws, the rest must complete with successes and failures distinguishable in the results.
The alternative given up: an existing library (JS's p-limit, Python's aiometer). Production should of course use one, and an interview wants it hand-written plus the volunteered "in production I use a library; writing it by hand shows I know what it does."
Streaming JSON parsing: ask which kind first
What the interviewer is really testing: this question's discrimination lies almost entirely in your first sentence. Somebody who hears "streaming JSON parsing" and starts writing a state machine spends 25 minutes on something probably buggy; somebody who first asks "is it one complete JSON per line, or one large object split into many chunks" has already half won.
The two cases differ by an order of magnitude in effort:
- (a) One complete JSON per line. SSE is like this: each event is a line of text beginning with
data:and the line holds complete JSON. LLM scenarios are this 99% of the time, and the solution is line buffering plus per-line parsing, twenty lines of code. - (b) One large JSON object arriving across chunks, such as a model returning a huge structured result you want to render while it streams. Only that needs genuine incremental parsing.
So the answer's order is: state the distinction, give (a)'s complete implementation, and finally say under what conditions (b) is worth building. Writing a state machine immediately is over-engineering, and over-engineering loses the same points in an interview as being unable to write it.
// (a) The SSE kind, one complete JSON per line: line buffering plus per-line parse.
function createLineParser(onEvent) {
let buffer = ''
return function feed(chunk) {
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? '' // the tail may be a half line, kept for the next round (D1's trap)
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue // skip blank, comment, and event lines
const payload = trimmed.slice(5).trim()
if (payload === '[DONE]') return // it is not JSON, and parsing it definitely throws
onEvent(JSON.parse(payload))
}
}
}import json
from typing import Any, Callable
def make_line_parser(on_event: Callable[[dict[str, Any]], None]) -> Callable[[str], None]:
buffer = ""
def feed(chunk: str) -> None:
nonlocal buffer
buffer += chunk
# Star unpacking in one step: the earlier items are complete lines and the last
# is a half line kept for the next round
*lines, buffer = buffer.split("\n")
for line in lines:
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
return
on_event(json.loads(payload))
return feed// Dependencies: Jackson (com.fasterxml.jackson.databind)
// Java has no built-in "split on a delimiter keeping the remainder", so buffer by hand.
final class LineParser {
private final StringBuilder buffer = new StringBuilder();
private final ObjectMapper mapper = new ObjectMapper();
private final Consumer<JsonNode> onEvent;
LineParser(Consumer<JsonNode> onEvent) {
this.onEvent = onEvent;
}
void feed(String chunk) throws JsonProcessingException {
buffer.append(chunk);
int nl;
while ((nl = buffer.indexOf("\n")) >= 0) {
String line = buffer.substring(0, nl).strip();
buffer.delete(0, nl + 1); // the remaining half line stays for the next round
if (!line.startsWith("data:")) continue;
String payload = line.substring(5).strip();
if (payload.equals("[DONE]")) return;
onEvent.accept(mapper.readTree(payload));
}
}
}// In real code URLSession's bytes(for:).lines already does the framing and half-line
// stitching (D1 covered it); this hand-written version is for the whiteboard.
// Swift has Codable, so decode straight into a concrete type.
struct StreamEvent: Decodable {
let delta: String
}
final class LineParser {
private var buffer = ""
private let decoder = JSONDecoder()
private let onEvent: (StreamEvent) -> Void
init(onEvent: @escaping (StreamEvent) -> Void) {
self.onEvent = onEvent
}
func feed(_ chunk: String) {
buffer += chunk
var parts = buffer.components(separatedBy: "\n")
buffer = parts.removeLast() // the tail may be a half line, kept for the next round
for line in parts {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("data:") else { continue }
let payload = trimmed.dropFirst(5).trimmingCharacters(in: .whitespaces)
guard payload != "[DONE]" else { return }
guard let data = payload.data(using: .utf8),
let event = try? decoder.decode(StreamEvent.self, from: data)
else { continue }
onEvent(event)
}
}
}You have seen these four's relatives on D1 and D25 — D1's typewriter in a terminal, D25's character-by-character rendering in a browser, and today's fifteen-minute whiteboard version. The same line buffer, a third time, so it deserves drilling to memory.
The complexity to volunteer: each character is scanned a constant number of times, so time is proportional to total bytes; memory holds only one unfinished line, so it is constant — "no need to accumulate the whole response in memory" is exactly why streaming parsing exists.
Three edge cases: a chunk split inside the JSON (handled by the buffer keeping the half line, which is what that pop or removeLast does); a chunk split inside the data: prefix (handled by the same line buffer, doing nothing until a newline arrives); and the closing [DONE] not being valid JSON, so parsing it necessarily throws. There is a hidden fourth, below.
And (b)? When you genuinely need to parse a large object incrementally, the core is three state variables: bracket depth (returning to zero means one complete object ended), whether you are inside a string (brackets inside a string must not count towards depth), and whether the previous character was a backslash (an escaped quote does not toggle the string state). All three are required, and missing one miscounts depth on text containing brackets.
state: depth=0 inString=false escaped=false
read '{' with inString=false -> depth+1
read '}' with inString=false -> depth-1; depth reaching zero emits a complete object
read '"' with escaped=false -> toggle inString
read '\' with inString=true -> escaped=true (applies to the next character only)
any other character -> escaped=falseThe alternative given up: waiting for all the data and parsing once. Functionally always correct, and given up for one reason, latency — the user waits for all 800 characters before seeing the first, so time to first character goes from 300 milliseconds to 12 seconds. "I know the simplest approach parses once everything has arrived, and I do not because of time to first character" connects a technical choice to user experience, which is this question's best closing.
Source Reading
Hands-On Lab
Today's output is not a new project, it is four pieces of code you can write from memory, plus a verification record for the weak-point list's fourth column. So turn off autocomplete and do not copy the code above — a whiteboard has neither.
The warm-up has one criterion: closed book, timed, and it runs. Run the edge cases listed in the text the moment you finish, fix it in place if it breaks, and note where you went wrong — whatever you got wrong today you will probably get wrong in the same place tomorrow.
- Spend 60 minutes on weak points first: open D28's four-column list, pick the two or three fixable in one repetition, execute the third column's smallest action for each, verify on the spot as the fourth column says, and write the result back into the list.
- Write a token bucket closed-book in 15 minutes and pass four edge cases: a cold-start burst, repeated takes at the same instant, capacity capping after idle time, and a clock going backwards. Then spend 5 minutes writing that Lua script from memory.
- Write an LRU closed-book in 15 minutes: first with your main language's built-in ordered container, then pretending the interviewer said no built-ins, rewritten with a hash map plus a doubly linked list. Both versions must pass "a get also refreshes order."
- Write a concurrency controller closed-book in 15 minutes and verify it with a fake task that throws at random: the concurrency peak never exceeds limit, the remaining tasks still complete after one fails, and an empty list does not hang.
- Write a streaming parser closed-book in 20 minutes: say which kind first, then write the line-buffered version, feed it three chunks deliberately split inside the JSON, and confirm the reassembled content is intact.
- Spend the last 10 minutes recording: where each of the four problems stalled, by how many minutes you overran, and how many mistakes you made, written into the improvement record as tomorrow's review input.
Interview Questions
Today's three questions are in the bank below, all spoken versions of coding problems — interviewers often ask for the approach before the code, and a poor approach scores badly however correct the code. LRU's points were covered fully in the text, so the bank carries three more discriminating ones. Expand a question and read the analysis before the key points, paying particular attention to each one's closing follow-up, which is these four problems' most common second question.
Checklist and Tomorrow
- Work through day 28's weak-point list item by item and record the improvement
- Independently implement a rate limiter and an LRU cache
- Implement a simple concurrency controller and a streaming JSON parser
- Explain the fixed window's boundary-doubling problem, and why distributed rate limiting needs a Lua script
- When told no built-in ordered containers, hand-write the hash-map-plus-doubly-linked-list LRU
- All four problems written closed-book within time and passing three edge cases, with the stumbles written into the improvement record
- Answer at least 2 of the 3 interview questions without looking at the key points
Tomorrow (D30) is the last day and does three things: one full pass over thirty days of interview questions, picking out the genuinely weak ones with a three-color marking; four weeks of content condensed into a knowledge map with prerequisites marked; and a month-two application cadence set with this site launched publicly. Today practiced isolated actions, and tomorrow connects them into a map you can explain yourself — capability first, structure second, and telling somebody else last, and reversing that order makes it empty talk.
Interview questions
What are the common rate limiting algorithms, what are their trade-offs, and which one would you actually ship?限流器有哪几种常见算法?各自的优缺点是什么?如果只能落地一种,你选哪个?
Common in ChinaCommon overseasBasic#rate-limiting#concurrencyHow to reason about it · think before answering
- This question tests whether you know rate limiting has several distinct semantics, not whether you can write a counter. Naming only one algorithm reads as never having run real traffic.
- Lay the four out by complexity and attach a weakness to each: fixed window is cheapest but has the boundary burst; sliding window log is exact but its memory grows with request count; sliding window counter is an approximation with constant memory; token bucket allows bursts with constant memory. That ordering is the skeleton of a good answer.
- Make the boundary burst concrete, because it is the standard follow-up: with a 100-per-minute limit, a client can spend 100 at 12:00:59 and another 100 the instant the counter resets at 12:01:00 — 200 requests inside two seconds, double the quota.
- Pick the token bucket and justify it by traffic shape: real traffic is bursty, and the bucket gives you two independent knobs — refill rate caps the long-run rate, capacity caps the burst. Implement it with lazy refill: compute the top-up from the elapsed time when a token is requested, never run a timer per user.
- Production angle: the in-memory version only holds for a single instance. Across gateway replicas, read-compute-write has a race and two replicas can both see 'one token left' and both allow. Fix it with a Redis Lua script so refill and deduction happen in one atomic step — Lua is not for speed here, it is for gluing three commands into one.
- Expect the follow-up: why not read the clock inside the script? Because that makes the script non-deterministic. Pass the timestamp in from the caller, and say the cost out loud — replica clocks now have to be roughly aligned.
分析过程 · 先想清楚再作答
- 这题在考「你知不知道限流有多种语义」,而不是「你会不会写计数器」。只答出一种算法的人,会被默认没做过真正的流量治理。
- 先把四种按复杂度排开再逐个给弱点:固定窗口最省内存但有边界双倍;滑动窗口日志最精确但内存和请求数同阶;滑动窗口计数是近似解、内存回到常数;令牌桶允许突发、内存常数。这个排列顺序本身就是答案的骨架。
- 边界双倍要用具体数字讲,它是本题最常见的追问:限每分钟 100 次,用户在 12:00:59 打满 100 次,12:01:00 计数器清零又能打 100 次,跨边界的这 2 秒实际放行了 200 次。说不出这个例子,等于没答第一问。
- 结论选令牌桶,理由要落在业务形状上:真实流量本来就是突发的,令牌桶同时约束了长期速率(补充速度)和瞬时突发(桶容量),两个旋钮分别对应两个业务问题。实现上必须是惰性补充——取的时候按时间差现算,不要给每个用户起一个定时器,十万用户就是十万个定时器。
- 生产视角:单机内存版只在单实例下成立。多个网关实例共享配额时,「读余额 → 算补充 → 写回」三步之间一定有竞态,两个实例都读到「还剩 1 个」就会双双放行。修法是把三步塞进一段 Redis Lua 脚本,靠单线程执行整段脚本拿到原子性——用 Lua 不是为了快,是为了把三条命令粘成一条。
- 可以预期的追问:脚本里为什么不直接取当前时间?因为那会让脚本变得不确定,时间戳应该由调用方传进来;代价是各实例的时钟要大致对齐,这个取舍要主动说出口。
Key points
- Four algorithms: fixed window (cheap, boundary burst), sliding window log (exact, memory grows with requests), sliding window counter (approximate, constant memory), token bucket (bursty, constant memory)
- The fixed-window boundary burst lets twice the quota through in the two seconds around a window edge, which is enough to overload a database or model API
- Ship the token bucket: refill rate bounds the long-run rate and capacity bounds the burst, two knobs for two real constraints
- Use lazy refill — top up from elapsed time on access instead of running one timer per key
- For the distributed version, put refill and deduction in one Redis Lua script; a GET followed by a SET always races. Pass the timestamp in to keep the script deterministic
答题要点
- 四种算法:固定窗口(省内存但边界双倍)、滑动窗口日志(精确但内存与请求数同阶)、滑动窗口计数(近似、常数内存)、令牌桶(允许突发、常数内存)
- 固定窗口的边界双倍:跨窗口交界的 2 秒内可以放行两倍配额,下游是数据库或模型 API 时足以打穿
- 落地选令牌桶:补充速度管长期速率、桶容量管瞬时突发,两个旋钮对应两个真实业务约束
- 必须用惰性补充:取令牌时按时间差现算,不要为每个 key 起定时器
- 分布式版把补充与扣减写进一段 Redis Lua 脚本,先 GET 再 SET 一定有竞态;时间戳由调用方传入以保持脚本确定性
How would you build a scheduler that caps in-flight async tasks, and why is Promise.all or asyncio.gather not enough?怎么实现一个限制并发数的调度器?为什么不能直接用 Promise.all 或者 asyncio.gather?
Common in ChinaCommon overseasIntermediate#concurrency#asyncHow to reason about it · think before answering
- The hinge is the second half. They are checking whether you separate 'await a batch' from 'cap how many run at once' — similar API names, unrelated semantics.
- Name the wrong answer first: mapping 500 items to promises and awaiting them together runs at concurrency 500. Creating the promise already fired the request; awaiting only collects results. gather and CompletableFuture.allOf are the same trap in other accents.
- Then give the two correct shapes: a fixed set of workers pulling from a shared cursor (the JS idiom, where a worker is the slot), or a semaphore gating task start (asyncio.Semaphore, java.util.concurrent.Semaphore). Swift needs a manual window over a TaskGroup — fill limit slots, then add one task per result received.
- The real failure mode is slot leakage: release must happen in a finally, or the error must be collapsed into a result value inside the task. Code that misses this looks perfect on the happy path and only degrades once the downstream starts failing, which makes it one of the hardest bugs to trace.
- Tie it to agents: batch embedding, parallel tool calls, fan-out subtasks. The benefit is not only sparing the downstream — peak memory now scales with the concurrency limit instead of the task count.
- Expect the follow-up: what if tasks retry? Retries must happen inside the slot, otherwise a retry storm bypasses the limiter entirely. One level deeper: add jitter so failed tasks do not all come back at the same instant.
分析过程 · 先想清楚再作答
- 题眼在后半句。面试官在确认你分不分得清「等待一批任务」和「限制同时运行的任务数」——这两件事在 API 名字上很像,在语义上毫无关系。
- 先说破错误答案为什么错:把 500 个任务全部映射成 Promise 再一起 await,这段代码的并发度是 500。Promise 一被创建,它内部的请求就已经发出去了,await 只是在等结果;gather 和 CompletableFuture.allOf 是同一个坑的另外两种口音。
- 再给正确形状的两条路:固定数量的工人从同一个游标取任务(JS 的惯用法,槽位就是工人本身),或者用信号量挡在任务启动之前(Python 的 asyncio.Semaphore、Java 的 Semaphore)。Swift 要用 TaskGroup 自己开滑动窗口,先塞满 limit 个、每收一个结果补一个。
- 本题真正的失分点是槽位泄漏:acquire 之后必须在 finally 里 release,或者把错误在任务内部收敛成结果值。忘了这一步的代码在 happy path 上完全正常,只有下游开始报错时才会一点点变慢直到彻底卡死——这是最难查的那类 bug,因为症状出现在故障之后而不是之中。
- 落到 Agent 场景说收益:批量 embedding、并行工具调用、多路子任务都靠它。收益不只是「不打爆下游」,还有同时驻留的内存与并发度同阶而不是与任务数同阶。
- 可以预期的追问:如果任务本身还要重试呢?答案是重试要在槽位内部完成(占着槽位退避重试),否则重试风暴会绕过限流;再追一层就是给重试加抖动,避免所有失败任务在同一时刻一起回来。
Key points
- Promise.all and asyncio.gather only wait; the work started when each promise was created, so concurrency equals the task count
- Two correct shapes: a fixed worker set pulling from a shared cursor, or a semaphore gating task start
- The slot must be returned on every exit path — finally in Java, async with in Python, error-to-value inside a Swift task, try/catch inside the JS loop
- A leaked slot shows up as gradual slowdown to a full stall once the downstream starts erroring, and is invisible on the happy path
- The payoff is peak memory scaling with the concurrency limit rather than the task count; retries must stay inside the slot and carry jitter
答题要点
- Promise.all 与 asyncio.gather 只负责等待,任务在被创建的那一刻就已经启动了,并发度等于任务总数
- 两种正确形状:固定数量的工人从共享游标取任务,或者用信号量挡在任务启动之前
- 槽位必须在任何退出路径上归还:Java 写在 finally 里,Python 用 async with,Swift 把错误收敛成结果值,JS 在循环里 try 与 catch
- 槽位泄漏的症状是「下游一开始报错就越来越慢直到卡死」,happy path 完全看不出来
- 收益是同时驻留的内存与并发度同阶,而不是与任务总数同阶;重试要占着槽位做,并加抖动
Why can't you just call JSON.parse in a streaming response, and how would you parse incrementally?为什么流式场景下不能直接用 JSON.parse?你会怎么做增量解析?
Common in ChinaCommon overseasDeep dive#streaming#json-parsingHow to reason about it · think before answering
- Almost all the signal is in your first sentence. Whoever starts writing a state machine will spend twenty-plus minutes on something probably buggy; whoever first asks 'is it one complete JSON per line, or one big object split across chunks?' has already won half the question.
- Answer the why first: network chunking ignores syntax boundaries, so a single read often holds half a JSON document. Handing that to a parser only throws, and the exception carries nothing you can recover from.
- Then draw the distinction. Case (a) is SSE: each event is one line prefixed with data, holding one complete JSON object. This covers 99% of LLM work, and the fix is line buffering plus per-line parsing — keep the trailing fragment in the buffer and stitch it onto the next chunk.
- Case (b) — one large object arriving in pieces — is the only case needing real incremental parsing, and it rests on three state variables: bracket depth (back to zero means a complete object), whether you are inside a string (brackets in text must not count), and whether the previous character was a backslash (an escaped quote must not toggle string state). Drop any one and text containing brackets breaks the depth count.
- One trap almost nobody volunteers: chunks are split on bytes, and a CJK character takes three bytes in UTF-8, so a boundary can land mid-character. Use a streaming decoder — TextDecoder with the stream option, an incremental decoder in Python, InputStreamReader in Java — or you get a replacement character you can never recover. It is the half-line problem one layer down.
- Expect the follow-up: what about the terminator line? It is not JSON, so check for it and return before parsing. Feeding it to the parser is the single most common one-line bug in this question.
分析过程 · 先想清楚再作答
- 这题的区分度几乎全在你开口的第一句话。听到「流式 JSON 解析」就动手写状态机的人,会花二十多分钟写一个大概率有 bug 的东西;先反问一句「是一行一个完整 JSON,还是一个大对象被切成很多片」的人,已经赢了一半。
- 先回答为什么不能直接解析:网络分包不认语法边界,一次读取拿到的很可能是半个 JSON。直接扔给解析器只会抛异常,而且这个异常没有任何可恢复的信息。
- 然后做那个关键区分。情况 a 是 SSE:每条事件是一行以 data 开头的文本,行内是完整 JSON,LLM 场景 99% 是这一种,解法是行缓冲加逐行解析,二十行代码——把切分出来的最后一段(可能是半行)留在缓冲区里,等下一次读到更多数据再拼。情况 b 是单个大对象跨分片到达,才需要真正的增量解析。
- 情况 b 的核心是三个状态变量:括号深度(深度归零说明一个完整对象结束)、是否在字符串内部(字符串里的括号不能计入深度)、前一个字符是不是反斜杠(转义中的引号不切换字符串状态)。三者缺一不可,少一个遇到含括号的文本就算错深度。
- 还有一条几乎没人主动说、但一说就加分的坑:分片是按字节切的,一个汉字在 UTF-8 里占三个字节,边界可能落在中间。必须用流式解码器(TextDecoder 的 stream 选项、Python 的增量解码器、Java 的 InputStreamReader),否则会拿到一个永远补不回来的乱码字符。这是「半行缓冲」在字节层的同款问题。
- 可以预期的追问:那结尾那个终止标记怎么办?答案是它不是 JSON,必须在解析前先判断并直接返回,拿它去解析必然抛异常——这是这道题里最常见的一行 bug。
Key points
- Network chunking ignores syntax boundaries, so a read can hold half a document; parsing it throws an unrecoverable error
- Ask which case it is first: one complete JSON per line (SSE, the overwhelming majority of LLM work) or one large object split across chunks
- The first case only needs line buffering plus per-line parsing, keeping the trailing partial line for the next chunk
- Only the second case needs a state machine, tracking bracket depth, inside-string, and escaped-previous-character
- One layer down, a multi-byte UTF-8 character can be split across chunks, so use a streaming decoder; and the terminator line is not JSON, so check for it before parsing
答题要点
- 网络分包不认语法边界,一次读取可能拿到半个 JSON,直接解析必然抛异常且不可恢复
- 先问清是哪一种:一行一个完整 JSON(SSE,占 LLM 场景的绝大多数)还是一个大对象跨分片到达
- 前者只需行缓冲加逐行解析:把最后一段可能的半行留在缓冲区,等下一次读到更多数据再拼
- 后者才需要状态机,核心是括号深度、是否在字符串内部、前一个字符是否为转义反斜杠三个状态
- 字节层还有一个同款坑:UTF-8 多字节字符可能被分片切开,必须用流式解码器;结尾的终止标记不是 JSON,解析前要先判断