Cruxly

Cruxly › Low-level design › LLD Exercises

Logging Framework

the calling thread must never wait on a disk

Open the interactive lesson

Prompt: 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:

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

caller thread check level, build event enqueue, RETURN bounded queue non-blocking offer writer thread one, dedicated the ONLY thread doing I/O console file network sink Queue full? Drop, don't block — a blocked caller defeats the entire design.
Everything left of the queue runs on the caller's thread and must be cheap. Everything right of it runs on the writer thread and can be as slow as the destination requires.
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

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons