Cruxly › Low-level design › LLD Exercises
Logging Framework
the calling thread must never wait on a disk
Open the interactive lessonPrompt: design an in-process logging framework. Support log levels (DEBUG/INFO/WARN/ERROR), multiple destinations (console, file, network) simultaneously, and per-package level configuration. A logging call must not measurably slow down the code that made it.
45 minutes. The interesting part is not the API — it is what happens between the call and the byte hitting disk.
Applying the five moves: nouns are LogRecord, Appender (one per destination), Level, and a LoggerConfig per package — the API surface (the log() call itself) is deliberately not the model, since the prompt is telling you the model lives in what happens after the call. The invariant is "the calling thread never performs blocking I/O" — this single sentence, stated before any class name, is what rules out the naive design where log() writes directly to a file. The interface seam is Appender, because "multiple destinations simultaneously" is the prompt naming variation explicitly — console, file, network are three implementations, and a fourth is a predictable follow-up. The concurrency sentence is the entire exercise: a producer (the calling thread, fast) and a consumer (the actual write, slow) connected by a queue, with the follow-up question being what happens when the queue fills — drop, block, or grow, and which one you'd choose and why.
Scope, and the constraint that shapes everything
In scope: leveled logging, multiple simultaneous destinations (sinks), per-package level filtering, a calling-thread cost close to zero. Out of scope: log aggregation across machines (a separate system — the metrics-pipeline exercise elsewhere in this track covers the same shape of problem for a different kind of data), structured/JSON output formatting specifics, log rotation policy details beyond naming that it exists.
The requirement "must not measurably slow the caller" is the whole design brief, restated as a constraint. A synchronous System.out.println or file write is fine at ten calls a second and a genuine throughput problem at ten thousand — disk and network I/O are three to five orders of magnitude slower than an in-memory operation, and a hot code path calling log.info(...) in a loop must not inherit that latency.
This immediately splits the design into two halves that barely resemble each other:
- The front door, on the caller's thread: decide whether to log (level check), format the minimum needed, and hand off. Must be fast — ideally a handful of nanoseconds when the level is filtered out.
- The back half, off the caller's thread: actually write to console, file, or network, at whatever speed those destinations allow.
Everything else in this exercise is deciding exactly where that hand-off happens and what it costs.
The classes
enum Level { DEBUG, INFO, WARN, ERROR } // ordinal order IS severity order
final class LogEvent {
final Level level;
final String logger; // usually the calling class's package/name
final String message;
final Object[] args; // unformatted — formatting happens off-thread
final long timestampNanos;
final Thread thread; // captured now; querying later gets the wrong thread
}
interface Appender { // one per destination
void append(LogEvent e);
void flush();
void close();
}
interface LevelFilter { // per-package configuration
boolean isEnabled(String loggerName, Level level);
}
LogEvent captures the calling thread and timestamp immediately, on the caller's thread, even though the event is written later. This is easy to get backwards: if the thread name or timestamp were read at write time instead, every log line would show the writer thread and a timestamp close to when the queue happened to drain — actively wrong information in exactly the tool you reach for to debug timing and concurrency issues.
args stays unformatted — the message template and its arguments cross the hand-off as data, and string formatting happens on the writer thread too. Formatting is not free, and the entire point of the front door is to do as little work as possible before handing off.
LevelFilter is an interface because per-package configuration is named in the prompt as a requirement, not a guess at future variation — the same test for "does this need a seam" used throughout this track.
The hand-off: why a queue, and what happens when it's full
final class AsyncLogger {
private final BlockingQueue<LogEvent> queue = new ArrayBlockingQueue<>(10_000);
private final List<Appender> appenders;
private final LevelFilter filter;
void log(String loggerName, Level level, String msg, Object... args) {
if (!filter.isEnabled(loggerName, level)) return; // fast path: nothing built at all
LogEvent e = new LogEvent(level, loggerName, msg, args,
System.nanoTime(), Thread.currentThread());
if (!queue.offer(e)) {
droppedCount.incrementAndGet(); // queue full: drop, never block
}
}
// runs on ONE dedicated writer thread, started once
private void writerLoop() {
while (running) {
LogEvent e = queue.poll(100, TimeUnit.MILLISECONDS);
if (e == null) continue;
for (Appender a : appenders) a.append(e); // slow I/O happens here, never on caller thread
}
}
}
filter.isEnabled() runs before anything else is built. A DEBUG call in a hot loop, filtered out at INFO level, should cost one boolean check and nothing more — no LogEvent allocation, no formatting, no queue interaction. This is worth stating as deliberate: the cheapest log call is the one that never happens, and level filtering exists precisely to make that the common case in production.
queue.offer(), never queue.put(). offer returns immediately — true if the event was enqueued, false if the queue was full. put would block the caller until space frees up, which silently reintroduces the exact synchronous-I/O-adjacent stall the entire design exists to avoid: a slow file appender backing up the queue would now stall every thread in the application that tries to log anything.
On a full queue: drop the event and count it, do not block. A dropped log line is a minor loss of information. A blocked application thread, caused by a logging call, in production, is an outage — and it is an outage whose root cause ("why did checkout hang") does not obviously point at logging at all, which makes it a genuinely nasty one to debug. Expose the drop count as its own metric, so silent data loss under load is at least visible somewhere.
Per-package filtering, and appender failures
Level filtering needs a hierarchy, not a flat map. Configuring com.example.payments = DEBUG should apply to com.example.payments.ledger too, unless that narrower package overrides it. Walk from the most specific configured ancestor outward:
final class HierarchicalFilter implements LevelFilter {
private final Map<String, Level> configured; // e.g. "com.example.payments" -> DEBUG
private final Level rootDefault;
public boolean isEnabled(String loggerName, Level level) {
String name = loggerName;
while (name != null) {
Level configured2 = configured.get(name);
if (configured2 != null) return level.ordinal() >= configured2.ordinal();
int dot = name.lastIndexOf('.');
name = dot < 0 ? null : name.substring(0, dot);
}
return level.ordinal() >= rootDefault.ordinal();
}
}
Walking outward from the full package name to its parents, stopping at the first configured ancestor, is what makes "turn on DEBUG for one noisy subsystem in production without touching anything else" possible — a genuinely common operational need, not a hypothetical one.
One appender failing must not silently disable the others. A network sink whose remote endpoint is down should not take down file and console logging just because they happen to share a writer loop:
for (Appender a : appenders) {
try {
a.append(e);
} catch (Exception ex) {
// log the failure to a LAST-RESORT appender (stderr), never rethrow here
selfLog(a, ex);
}
}
This is the same isolation principle as the notification system's per-channel error handling elsewhere in this track: one destination's failure mode must not leak into the others' — and it applies even though here all destinations share one writer thread, which makes it easy to forget that they still need independent failure isolation.
Backpressure from a slow appender is a real, separate problem from a full queue. If the file appender is slow (a full disk, a stalled network mount), the writer loop processes events slower than they arrive, and the queue fills regardless of how fast production stages are. The two fixes are independent: a bounded queue with drop-on-full handles burst volume; a per-appender timeout (skip an appender that hasn't returned within, say, 50ms, and count that as a separate failure) handles a chronically slow destination before it can back up everything behind it.
Shutdown, and the data-loss trap most implementations miss
On normal shutdown, drain the queue before exiting — do not simply stop the writer thread. Events already accepted and sitting in the queue represent log lines the application believes were captured. Silently discarding them on shutdown is a correctness bug with the same shape as an acknowledged-but-unpersisted write anywhere else in this track: the caller was told (implicitly, by not blocking) that the log call succeeded, and a shutdown that drops the queue breaks that implicit promise.
void shutdown() {
running = false;
List<LogEvent> remaining = new ArrayList<>();
queue.drainTo(remaining); // grab whatever is left, non-destructively
for (LogEvent e : remaining) {
for (Appender a : appenders) a.append(e); // flush synchronously, on the shutdown thread
}
for (Appender a : appenders) { a.flush(); a.close(); }
}
This is the one place synchronous I/O on a "caller" thread is correct — shutdown is no longer the hot path the whole design was protecting; it is a one-time drain where correctness (not losing accepted events) matters more than the latency guarantee that applied during normal operation. Register this via a JVM shutdown hook so it runs even on a normal Ctrl+C or SIGTERM, not only on an explicit logger.shutdown() call the application might forget to make.
What still gets lost, and why that is an acceptable, named limit: a hard crash (kill -9, a segfault, a power failure) loses whatever was in the queue at that instant, because nothing survives a process that vanishes without running its shutdown hooks. This is the same durability boundary every in-memory buffer has, and the honest answer is to name it rather than imply the design is crash-proof: "logging is best-effort against a hard crash — the same trade-off as the rate limiter's in-memory bucket state. If losing the last few log lines before a crash is unacceptable, that specific stream needs synchronous, not buffered, writes — a deliberate trade against the latency requirement everywhere else."
How to say it in the interview
"The requirement that a log call must not measurably slow the caller means logging cannot do synchronous I/O on the calling thread — so the design is really two halves: a fast, non-blocking front door, and a separate, slower path that actually writes."
Checklist
- Split the design into a fast, non-blocking front door and a slower off-thread writer
- Captured timestamp and calling thread at log-call time, not at write time
- Kept message arguments unformatted until the writer thread
- Checked the level filter before building anything, so a filtered call allocates nothing
- Used a non-blocking offer onto a bounded queue rather than a blocking put
- Dropped events on a full queue rather than blocking, and counted the drops
- Implemented hierarchical package-based filtering, not a flat per-class map
- Isolated one appender's failure from the others despite sharing one writer thread
- Distinguished burst backpressure (queue full) from a chronically slow appender, with separate fixes
- Drained the queue synchronously on shutdown instead of discarding it
- Registered the drain as a JVM shutdown hook, not only an explicit method call
- Named the hard-crash data-loss boundary explicitly rather than implying the design is crash-proof