11. Low-Level Design Interview Walkthroughs¶
Time: ~5 hours Prerequisites: 01 to 08 (06, 07 and 08 especially)
1. In plain words¶
The low-level design (LLD) round, sometimes called "machine coding" or "object-oriented design", gives you a small system ("design a parking lot", "design a rate limiter") and 45 to 60 minutes. You are expected to ask questions, pick classes and their responsibilities, draw a class diagram, write the core of the code, and discuss how it changes when requirements change: more threads, more types, more scale.
It is not a test of whether you know the "right" answer. There is no right answer. It is a test of whether you work like a senior engineer: you clarify before building, you keep the design as simple as the requirements allow, you name trade-offs out loud, and the code you write compiles and handles the edge cases.
This module gives you a repeatable method and seven complete walkthroughs. Each has a code
file in code/ that compiles and checks itself. Practise each one against a 45-minute timer
until you can do it without looking.
2. The real thing¶
2.1 The method (use it every time)¶
0-5 min 1. CLARIFY functional requirements, scale, concurrency, what is out of scope
5-10 min 2. ENTITIES nouns -> classes / value types; verbs -> methods
3. VARIATION what will change? -> interface, std::function, or template
10-15 min 4. DIAGRAM boxes, ownership (<>-- composition), uses (-->), multiplicity
15-40 min 5. CODE core data structures and the 2-3 central methods, compiling
40-45 min 6. TEST + EXTEND walk one example through; edge cases; threading; scale; trade-offs
1. Clarify. Ask, then write the answers down where the interviewer can see them:
- What operations? What inputs and outputs? What errors?
- How many items, requests per second? (Decides data structures.)
- Single-threaded or concurrent? (Decides locking.)
- What happens on failure, on full, on duplicate?
- What is explicitly out of scope (persistence, distribution, UI)?
2. Entities. Underline the nouns. Each becomes a class if it has behaviour or
invariants, otherwise a plain struct. Value types (Ticket, Record) are cheap and
copyable; entities with identity (Level, ParkingLot) are usually owned in one place.
3. Variation. The interviewer's follow-ups ("now add a new vehicle type", "now a
different pricing") target the parts that vary. Put them behind a strategy now, but only
those: a FeePolicy, a SpotAllocator, a rate-limit Algorithm. One operation:
std::function. Several operations: an interface. Fixed at compile time and hot: template.
4. Diagram. ASCII on the whiteboard or the shared editor:
+-----------+ 1 * +-------+ <>-----> composition (owns, lifetime bound)
| Owner |<>------>| Part | -------> uses / depends on
+-----------+ +-------+ ---|> implements / inherits
| method() |
+-----------+
5. Code. Start with the data structures and the central method. Use the standard
library. Show ownership (unique_ptr, references), const, error returns. Leave
boilerplate (getters, printing) for the end or skip it with a comment.
6. Test and extend. Walk one example through the code out loud. Then discuss: thread safety (one mutex first, then striping or sharding), scale (what breaks at 10x), testability (injected clock), and the trade-offs of the choices you made.
Things that lose points: coding before clarifying; a class per noun with no behaviour ("Manager" classes that hold one map); inheritance hierarchies three deep for a two-variant problem; global singletons; not handling the full/empty/duplicate case; silence (the interviewer cannot grade what you do not say).
2.2 Walkthrough 1: parking lot¶
Clarify. Levels with Small/Medium/Large spots. Motorcycle fits any spot, Car fits Medium or Large, Truck only Large. Park returns a ticket or "full"; unpark returns a fee. Fee per hour by vehicle type, first 15 minutes free. Several entry gates (concurrent). Out of scope: payments, reservations, displays.
Entities and variation. Vehicle (value), Spot (identified by level and index),
Level (free spots per size), Ticket (value), ParkingLot (facade). Varies: how a spot is
chosen (SpotAllocator), how fees are computed (FeePolicy).
+--------------------+ 1 * +----------------------+
| ParkingLot |<>------>| Level |
| park(v, t) | | take(size) |
| unpark(id, t) | | give_back(size, i) |
| free_spots(size) | | free_: set<int>[3] |
+--------------------+ +----------------------+
| uses | uses * active
v v v
SpotAllocator FeePolicy +-----------+
(std::function)(std::function)| Ticket | id, plate, type, spot, size, entry
+-----------+
Key code (code/m11_parking_lot.cpp):
// Smallest fitting size first, lowest level first within that size.
std::optional<std::pair<SpotId, SpotSize>>
smallest_fit_lowest_level(std::vector<Level>& levels, VehicleType t) {
for (auto s = static_cast<std::size_t>(smallest_fit(t)); s < kSizes; ++s)
for (auto& lvl : levels)
if (auto spot = lvl.take(static_cast<SpotSize>(s)))
return std::make_pair(*spot, static_cast<SpotSize>(s));
return std::nullopt;
}
Each Level keeps a std::set<int> of free spot indices per size, so "nearest free spot" is
*begin() in O(log n). The lot keeps active_ tickets by id and a plates_ map so the same
car cannot park twice.
Talking points.
- Why not
class Car : public Vehicle? Vehicles have no behaviour here; the only difference is size class. An enum plus onesmallest_fit()function is simpler. Use inheritance when types behave differently. - Concurrency: one mutex around
park/unpark. The critical section is microseconds; gates are a handful of threads. Per-level locks add deadlock risk (a car spilling to the next level needs two) for no measurable gain. - Extensions: electric spots with chargers (a new
SpotSizeor a feature flag on spots and a different allocator); reservations (aReservedstate per spot with expiry); display boards (observer on free-count changes).
2.3 Walkthrough 2: LRU cache with TTL¶
Clarify. get, put(key, value, ttl), erase. Capacity in entries; evict least
recently used when full. Entries expire after their TTL. O(1) operations. Thread-safe.
Testable without sleeping.
Design. A doubly linked list in recency order plus a hash map from key to list
iterator. Each list node stores key, value and expiry time. Expiry is checked lazily on
get and by an O(n) purge_expired() sweep called from a timer.
+---------------------------+ uses +-------------+
| LruTtlCache<K,V> |------>| Clock |<|--- SteadyClock, FakeClock
| get(k) -> optional<V> | +-------------+
| put(k, v, ttl?) |
| erase(k), purge_expired()| index_: unordered_map<K, list<Entry>::iterator>
+---------------------------+ lru_: list<Entry> (front = most recent)
^ wraps Entry { K key; V value; TimePoint expires; }
+---------------------------+
| SyncLruTtlCache (mutex) |---> ShardedLruTtlCache<K,V,N>: N shards by hash(key)
+---------------------------+
Key code (code/m11_lru_ttl.cpp):
std::optional<V> get(const K& key) {
auto it = index_.find(key);
if (it == index_.end()) { ++stats_.misses; return std::nullopt; }
auto node = it->second;
if (is_expired(*node)) { remove(it); ++stats_.expirations; ++stats_.misses; return std::nullopt; }
lru_.splice(lru_.begin(), lru_, node); // touch: O(1), no allocation, iterators stay valid
++stats_.hits;
return node->value; // a copy: a reference would dangle after eviction
}
Talking points.
spliceis the key trick: it relinks a node without copying or reallocating, and other iterators in the map stay valid.getmutates (moves the node, updates stats), so ashared_mutexdoes not help: every call is a writer. To scale, shard by key hash. Sharding makes LRU approximate globally.- Lazy expiry alone leaks memory for keys never read again; hence the periodic purge. For millions of entries, keep a min-heap or a timing wheel of expiry times so purging is O(expired) instead of O(n).
- Time comes from an injected
Clockand usessteady_clock, never wall time, which jumps. - Alternatives worth naming: CLOCK (second-chance) approximates LRU with a bit per entry and no list reordering, which is cheaper under concurrency; LFU when frequency matters more than recency.
2.4 Walkthrough 3: in-memory KV store with transactions¶
Clarify. SET, GET, UNSET, COUNT value, BEGIN, ROLLBACK, COMMIT.
Transactions nest; ROLLBACK undoes the innermost; COMMIT makes everything permanent.
GET and COUNT O(1). Single client (ask!).
Design choice, say it out loud:
| Undo log (chosen) | Overlay layers | |
|---|---|---|
| Write | apply to live map, record before-image once per key per tx | write into the top layer only |
| Read | O(1), live map | O(depth): search layers top-down |
| Rollback | replay before-images | drop the top layer |
| Commit | discard logs (or merge into parent) | merge layers down |
| Isolation for other clients | none: they see uncommitted writes | natural: base untouched until commit |
+-----------------------------+
| TxKvStore | data_: unordered_map<string,string>
| set/get/unset/count | counts_: unordered_map<string,size_t> (value -> #keys)
| begin/rollback/commit_* | txs_: vector<Tx> (stack)
+-----------------------------+ Tx { unordered_map<string, optional<string>> undo; }
^ used by
execute(db, "SET a 10") -- the protocol layer, kept out of the store
Key code (code/m11_kv_transactions.cpp):
void write(const std::string& k, std::optional<std::string> v) {
if (!txs_.empty()) txs_.back().undo.emplace(k, get(k)); // first write wins: keeps the ORIGINAL
raw_write(k, std::move(v)); // the only function that touches data_
}
[[nodiscard]] bool rollback() {
if (txs_.empty()) return false;
Tx tx = std::move(txs_.back());
txs_.pop_back();
for (auto& [key, before] : tx.undo) raw_write(key, before);
return true;
}
Talking points.
emplacedoes not overwrite, so a key written twice in one transaction keeps its value from before the transaction, which is what rollback must restore.COUNTstays O(1) becauseraw_writemaintainscounts_; one choke point for the invariant.- Nested commit (
commit_innermost) merges the child's undo entries into the parent, keeping the parent's own before-image when both touched a key. - Multi-client follow-up: overlay layers per transaction give read-committed isolation;
conflict detection at commit gives optimistic concurrency; this is how MVCC engines think
(see
07_Databases_Internals).
2.5 Walkthrough 4: logger library¶
Clarify. Levels, runtime minimum level, several sinks, file and line, thread id, timestamps. Asynchronous: request threads must never block on disk. What on overflow: drop and count, or block? (Ask; for a server the answer is usually drop.) Flush on demand and on shutdown.
LOG(lg, Level::Info) << "loan " << id; macro: if disabled, nothing to the right is evaluated
|
v
+-----------+ submit(Record) +--------------------------+ write(Record) +-------------+
| LogLine |----------------->| Logger |--------------->| Sink |<|-- Console
| (RAII: | | level_ (atomic) | | write() |<|-- Memory
| submits in| | queue_ (bounded deque) | | flush() |<|-- File
| dtor) | | worker_ thread | +-------------+
+-----------+ +--------------------------+
Key code (code/m11_logger.cpp):
#define LOG(lg, lvl) \
if (!(lg).enabled(lvl)) {} else LogLine((lg), (lvl), __FILE__, __LINE__).stream()
void run() { // the background thread
std::deque<Record> batch;
std::unique_lock<std::mutex> lk(m_);
for (;;) {
cv_.wait(lk, [&] { return stopping_ || !queue_.empty(); });
if (queue_.empty() && stopping_) return;
batch.swap(queue_); // one lock per batch, not per record
in_flight_ = batch.size();
lk.unlock(); // write OUTSIDE the lock: sinks may be slow
for (auto& r : batch) for (auto& s : sinks_) s->write(r);
batch.clear();
lk.lock();
written_ += in_flight_; in_flight_ = 0;
drained_cv_.notify_all(); // wakes flush()
}
}
Talking points.
- The macro's
if (...) {} elseshape means a disabledLOG(...) << expensive()costs one relaxed atomic load and a branch;expensive()is never called. It also nests safely inside an unbracedif/else. - A
Recordowns its message (std::string): it crosses threads, so a pointer into the caller's stack would dangle. - The worker thread is the last member: constructed after everything it touches, and the destructor stops and joins it before members are destroyed.
- Bounded queue with drop counting: logging must never take the server down. Expose the drop counter as a metric.
- Follow-ups:
fmt-style formatting done on the background thread (pass arguments, not strings); a lock-free MPSC queue; file rotation; rate-limited logging for repeated errors.
2.6 Walkthrough 5: rate limiter¶
Clarify. allow(key) per customer or API key. Which algorithm? Burst allowed? Exact or
approximate? Memory per key? Distributed across servers (usually: "first single node, then
discuss")?
| Algorithm | State per key | Bursts | Exactness | Notes |
|---|---|---|---|---|
| Token bucket | tokens, last refill time | up to capacity | exact long-run rate | default choice |
| Fixed window | window index, count | 2N at a boundary | approximate | cheapest |
| Sliding window log | timestamps of accepted requests | none | exact | memory O(N) per key |
| Sliding window counter | two window counts | small | approximate (weighted) | good compromise |
+----------------------------+ owns * +----------------------------+
| KeyedRateLimiter |<>------->| Algorithm |<|-- TokenBucket
| allow(key) | per key | try_acquire(now) -> bool |<|-- FixedWindow
| evict_idle() | +----------------------------+<|-- SlidingWindowLog
| stripes_[16]: mutex + map |
| factory_, now_ (injected) |
+----------------------------+
Key code (code/m11_rate_limiter.cpp):
bool TokenBucket::try_acquire(TimePoint now) {
if (now > last_) { // lazy refill: no timer thread per key
const double secs = std::chrono::duration<double>(now - last_).count();
tokens_ = std::min(capacity_, tokens_ + secs * rate_);
last_ = now;
}
if (tokens_ < 1.0) return false;
tokens_ -= 1.0;
return true;
}
Talking points.
- Algorithms are single-key, single-threaded and take
nowas a parameter: pure functions of their state, trivial to test. The keyed wrapper adds lookup, locking and eviction. - Lock striping (16 mutexes by key hash) means two different customers rarely contend.
- Idle keys are evicted, or memory grows with every client ever seen.
- Distributed: a shared store (Redis with an atomic script) for exact global limits at the
cost of a network hop per request, or local limits of
global / serverswith periodic rebalancing, which is approximate but free. - Where it sits in a server: as middleware before the service (Module 06, chain of responsibility), returning a "try later" error with a retry-after hint.
2.7 Walkthrough 6: loan-application workflow engine¶
This one is your home ground. Interviewers at fintech companies ask it in many forms ("design an order workflow", "design a KYC pipeline").
Clarify. States: Draft, Submitted, UnderReview, Approved, Rejected, Disbursed, Cancelled. Events: Submit, StartReview, Approve, Reject, Disburse, Cancel. Who may trigger what (roles)? Guard rules per transition (KYC done, age range, product limit, FOIR, maker cannot approve own application). Return all failed rules, not just the first. Side effects after success (letters, posting to core banking). Full audit trail.
Draft --Submit--> Submitted --StartReview--> UnderReview --Approve--> Approved --Disburse--> Disbursed
| | | \--Reject--> Rejected
+------Cancel-------+-----------Cancel---------+-----------Cancel----+--> Cancelled
+-------------------------+ owns +----------------------------------------------+
| WorkflowEngine |<>---->| map<(State, Event), Transition> |
| add(from, ev, t) | | Transition { to; set<Role>; vector<Rule>; |
| fire(app, ev, actor) | | vector<Action> } |
| available_events(s) | +----------------------------------------------+
+-------------------------+ Rule { name; function<optional<string>(app, actor)> }
| mutates Action function<void(app&, actor)>
v
+-------------------------+ 1 * +------------+
| Application |<>---->| AuditEntry | from, to, event, user, accepted, reasons
+-------------------------+ +------------+
Key code (code/m11_workflow_engine.cpp):
Outcome fire(Application& app, Event ev, const Actor& actor) const {
const State from = app.state;
std::vector<std::string> reasons;
auto it = table_.find({from, ev});
if (it == table_.end()) {
reasons.push_back(std::string("event not allowed in state ") + name(from));
} else {
const Transition& t = it->second;
if (!t.allowed_roles.count(actor.role)) reasons.push_back("role not permitted");
for (const Rule& r : t.rules) // run ALL rules
if (auto why = r.check(app, actor)) reasons.push_back(r.name + ": " + *why);
}
const bool ok = reasons.empty();
const State to = ok ? it->second.to : from;
app.audit.push_back(AuditEntry{from, to, ev, actor.user, ok, reasons});
if (!ok) return Outcome{false, from, std::move(reasons)};
app.state = to;
for (const Action& a : it->second.actions) a(app, actor);
return Outcome{true, to, {}};
}
Talking points.
- The engine is generic; the loan knowledge is in the table and the rules. A gold-loan product is a different table, not a different engine. The table can be loaded from a database configuration table, which is how lending platforms let operations change a workflow without a release.
- Rules are small named functions with captured parameters (
foir_at_most(5000)), each unit-testable on its own. Money is integer paise; ratios are integer basis points. - Why not the GoF state pattern with a class per state? Behaviour per state here is "which transitions exist and which rules guard them", which is data. Classes would scatter the table across seven files.
- Persistence: state change, audit row and outbox rows for actions commit in one database
transaction. Concurrency: two officers acting on one application at once is prevented with
optimistic locking (a version column checked in the
UPDATE ... WHERE version = ?). - Extensions: SLA timers (escalate if UnderReview over 48 hours: the job scheduler below); parallel approvals (a sub-state counting approvals); deviation workflows (a rule failure routes to a higher approver instead of refusing).
2.8 Walkthrough 7: job scheduler¶
Clarify. Run once at a time; run every period; cancel. Run jobs on a worker pool so a slow job does not delay others. Retry failures with back-off? Missed runs after a pause: run all, run once, or skip? Persistence across restarts (usually out of scope; mention a jobs table)?
Design, the key idea: split pure logic from threads.
+---------------------------+ owns +-------------------------------+
| Scheduler (threaded) |<>--->| SchedulerCore (pure) |
| schedule_after/every | | schedule_at(when, fn, retry) |
| cancel(id), stop() | | run_due(now) -> vector<Due> |
| dispatcher_ thread | | report(id, ok, now, backoff) |
| workers_ threads | | heap_: min-heap(due,seq,id) |
| ready_ deque | | jobs_, in_flight_: id -> Job |
+---------------------------+ +-------------------------------+
Key code (code/m11_job_scheduler.cpp):
void dispatch() { // dispatcher thread
std::unique_lock<std::mutex> lk(m_);
while (!stopped_) {
auto next = core_.next_due();
if (!next) { cv_.wait(lk); continue; }
if (cv_.wait_until(lk, *next) == std::cv_status::no_timeout) continue; // woken: re-check
for (auto& d : core_.run_due(Clock::now())) ready_.push_back(std::move(d));
work_cv_.notify_all();
}
}
Talking points.
- The core takes
nowas an argument, so every ordering, cancellation, catch-up and retry rule is tested deterministically without threads or sleeps. The threaded shell is thin. - Min-heap keyed by (due time, sequence number): the sequence number gives FIFO order for jobs due at the same instant.
- Cancellation is lazy: erase from the job map; the heap entry is skipped when it surfaces. Removing from the middle of a binary heap would be O(n).
wait_untilis woken early by a new job that is due sooner than the current head; the loop re-reads the head after any wake-up (and spurious wake-ups are handled by the same path).- Fixed rate (next = previous due + period) does not drift; fixed delay (next = finish + period) never overlaps itself. After a long pause this core runs a missed job once, not once per missed period. Say which you chose.
- Jobs run outside the lock, and exceptions are caught in the worker so one bad job cannot kill a worker thread.
- Scale: millions of timers use a hierarchical timing wheel (O(1) insert, as in the Linux kernel's timer wheel and Kafka's purgatory) instead of a heap.
3. Code¶
One file per walkthrough, each with a main() of assert-based checks:
| File | Contents |
|---|---|
code/m11_parking_lot.cpp |
Level with per-size free sets, allocator and fee strategies, ParkingLot facade with a mutex, concurrent-gate test |
code/m11_lru_ttl.cpp |
LruTtlCache (list + map + splice), lazy and swept expiry, stats, SyncLruTtlCache, ShardedLruTtlCache, FakeClock |
code/m11_kv_transactions.cpp |
TxKvStore with undo log, O(1) COUNT, nested rollback, flat and nested commit, text command interpreter |
code/m11_logger.cpp |
Logger (sync/async, bounded dropping queue, flush), Sink interface, LogLine RAII, LOG macro |
code/m11_rate_limiter.cpp |
token bucket, fixed window, sliding log, KeyedRateLimiter with lock striping and idle eviction |
code/m11_workflow_engine.cpp |
table-driven WorkflowEngine, rules (KYC, age, amount, FOIR, maker-checker), actions to an outbox, audit trail |
code/m11_job_scheduler.cpp |
pure SchedulerCore (heap, lazy cancel, catch-up, retry with back-off) and a threaded Scheduler |
Build any of them:
g++ -std=c++17 -Wall -Wextra -Werror -pthread -fsanitize=address,undefined -o lld code/m11_lru_ttl.cpp && ./lld
g++ -std=c++17 -Wall -Wextra -Werror -pthread -fsanitize=thread -o lld code/m11_job_scheduler.cpp && ./lld
4. Under the hood (senior-level)¶
std::list node cost. Each node is a separate heap allocation holding two pointers plus
the element: for Entry<std::string, std::string> on 64-bit libstdc++ that is 16 bytes of
links plus two 32-byte strings plus the time point, before malloc overhead. Lookups chase a
pointer from the hash bucket to the map node, then to the list node: two likely cache misses
per get. High-performance caches use an intrusive list (links inside a slab-allocated
entry) or an array-based CLOCK to cut this to one miss.
std::unordered_map in libstdc++ is a vector of buckets, each a pointer into one singly
linked list of nodes. reserve(n) sizes the bucket array once, so the hot path never
rehashes; a rehash is O(n) and invalidates iterators (but not references or pointers to
elements). That is why the LRU stores list iterators in the map and never map iterators
anywhere.
std::priority_queue is a binary heap in a std::vector: push and pop are O(log n) with
good locality, and there is no way to remove an arbitrary element, which is why the scheduler
cancels lazily. A timing wheel is an array of buckets indexed by due / tick % size;
insertion is O(1) and the cost moves to the tick.
Condition variables and wait_until. On Linux, std::condition_variable::wait_until
with a steady_clock time point uses pthread_cond_clockwait with CLOCK_MONOTONIC (glibc
2.30 and later; libstdc++ uses it when available). Older combinations converted to the
system clock, so a wall-clock jump could delay or hasten wake-ups. This is one more reason
to keep every deadline on steady_clock.
Floating point in a token bucket. double tokens accumulate rounding error over billions
of refills; it does not matter for a rate limiter, but in billing it would. An integer
variant stores tokens in micro-tokens or computes refill from integer nanoseconds.
Mutex cost in these designs. An uncontended std::mutex lock/unlock on Linux is a pair of
atomic operations in user space (futex fast path), on the order of tens of nanoseconds
(typical). Contended, it becomes a futex syscall and a context switch, microseconds. This
is why one mutex is fine for a parking lot and striping matters for a rate limiter that sees
every request.
5. In production¶
The LRU that ate the heap. A cache keyed by request-supplied strings with no capacity
limit, or with capacity counted in entries while values were megabyte-sized documents.
Capacity should be in bytes when values vary in size, with the size of each entry computed
at put.
Rate limiting the wrong key. Limiting by client IP behind a load balancer that does not pass the original address limits everyone at once. In a branch-banking network, many users share one NAT address; limit by authenticated user or branch id instead.
Logger shutdown order. A global logger destroyed before a global object whose destructor
logs: use-after-destruction at exit, visible as a crash in atexit handlers. Owning the
logger in main and passing it down (Module 09) makes the order explicit.
Workflow tables changed live. Operations removed a transition from the configuration table while 200 applications were in that state, stranding them. Validate a new table against current data (every non-terminal state with live applications must still have an exit) before activating it.
Scheduler and clock changes. A daily job scheduled with system_clock ran twice on the
day the server's clock was corrected backwards by NTP. Use steady_clock for intervals and
compute "next civil time" (02:00 IST) explicitly from a time zone each time, never by adding
86,400 seconds.
In the interview room. If the interviewer pushes toward a distributed version, say what changes: state moves to a shared store, operations become idempotent with request ids, locks become leases with expiry, and you now need to discuss what happens during a network partition. Then return to the single-node design they asked for.
6. Try it¶
- (easy) Parking lot: add an
ElectricCarthat must take a spot with a charger. Done when only the allocator and fit rule changed and a test proves it does not take a plain Medium spot. - (easy) KV transactions: add
NUMEQUALTOas an alias ofCOUNTand anENDcommand that stops a script. Done when the script test inmaincovers both. - (medium) LRU: make capacity count bytes (
key.size() + value.size()) instead of entries. Done when one largeputevicts several small entries and a value larger than capacity is rejected. - (medium) Rate limiter: implement the sliding window counter (two fixed windows,
weighted by overlap) and show it refuses the boundary burst that
FixedWindowallows. Done when its test sits next to the fixed-window test. - (hard) Workflow engine: add an SLA. If an application stays
UnderReviewlonger than 48 hours, anEscalateevent moves it to a newEscalatedstate. UseSchedulerCorefromm11_job_scheduler.cppwith a fake time to drive it. Done when the test advances time and sees the escalation, and a decision before the deadline cancels the timer. - (hard) Timed practice: for each of the seven problems, start a 45-minute timer, open an empty file, and produce clarifications, a diagram and compiling code. Done when you finish all seven within time, then compare with the code files.
7. Interview questions¶
Q: Walk me through how you approach an LLD problem. A: Clarify requirements, scale, concurrency and scope first and write them down. Identify entities and the parts likely to vary, which become strategies. Draw a class diagram with ownership. Code the core data structures and two or three central methods so they compile, then walk an example, cover edge cases, and discuss threading, scale and trade-offs.
Q: Why a list plus a hash map for LRU, and why splice?
A: The map gives O(1) lookup of the node, the list keeps recency order with O(1) unlink and
relink, and splice moves a node to the front without allocation while keeping all other
iterators valid. Storing list iterators in the map is safe because list iterators are never
invalidated by operations on other nodes.
Q: How would you make your LRU cache thread-safe and scalable?
A: Start with one mutex around the operations; note that get mutates recency so a
reader-writer lock does not help. To scale, shard by key hash with an independent cache and
lock per shard, accepting approximate global LRU, or move to a CLOCK-style design where hits
only set a bit atomically.
Q: Token bucket vs sliding window? A: Token bucket allows controlled bursts up to its capacity and enforces a long-run rate with two numbers of state; it is the usual default. A sliding window log enforces an exact count in any trailing window but stores a timestamp per accepted request; the sliding window counter approximates it with two counters.
Q: Undo log or overlay for transactions in a KV store? A: Undo log keeps reads O(1) and makes rollback replay the before-images, but other clients see uncommitted writes. Overlay layers keep the base untouched until commit, giving isolation at the cost of O(depth) reads. The choice depends on whether multiple clients read concurrently.
Q: How do you design a state machine for a business workflow? A: A table of (state, event) to transition, each with allowed roles, guard rules and actions; a generic engine that runs all guards and reports every failure, records an audit entry for every attempt, changes state, then runs actions. Persist state, audit and outbox in one transaction and use optimistic locking against concurrent edits.
Q: How do you test a scheduler without sleeping?
A: Separate a pure core that takes now as a parameter from the thread that waits. All
ordering, cancellation and retry logic is tested by calling the core with chosen times; the
threaded shell gets a couple of short real-time smoke tests.
Q: In an async logger, what happens when the queue is full? A: Choose explicitly: drop and count (protects request latency; the right default for a server), block (never loses logs, can stall the service), or drop low-severity records first. Expose the drop count as a metric so data loss is visible.
8. Recap¶
- Method: clarify, entities, variation, diagram, code, test and extend. Talk throughout.
- Make only the varying parts pluggable;
std::functionfor one operation, interfaces for several. - Parking lot: enum plus fit rule over a vehicle hierarchy; sets of free indices; one mutex.
- LRU with TTL: list plus map plus
splice; lazy and swept expiry; inject the clock; shard to scale. - KV transactions: undo log with first-write-wins before-images; one choke point for counts.
- Logger: macro that skips disabled work, RAII line, owned records, bounded dropping queue, write outside the lock.
- Rate limiter: pure single-key algorithms taking
now; striped keyed wrapper; idle eviction. - Workflow engine: data-driven table, all guards reported, audit always, actions via outbox.
- Scheduler: pure core plus thin threaded shell; heap with sequence tie-break; lazy cancel.