Learning pathsPricingChecking sign-in…
← LLD problem library

LLD-011 · DESIGN PRACTICE

Logging Framework

Compose filters, immutable events and sinks while stating queue and delivery guarantees honestly.

Local Java 21 reference verified: 18 checks. Learner drafts are not executed. Author self-review; independent review pending.

Existing introductory lesson · Version 1

Understand the problem

Design a logger with severity filtering, structured fields, formatter/sink separation, failure isolation and a bounded queued sink. Explain why a successful logging call, queue admission and durable output are different events.

Before you start

  • Dependency injection and narrow interfaces
  • Immutable event snapshots
  • Producer/consumer backpressure
  • Lock scope and shutdown semantics

Scope and explicit decisions

  • The core Logger invokes configured sinks synchronously outside its short sequence-allocation monitor. No global mutable logger registry, filesystem rotation, background thread, remote collector or durable storage is implemented.
  • Level order is DEBUG<INFO<WARN<ERROR. Events below the configured threshold return before event construction and do not invoke sinks. Caller-side argument computation has already happened; this is not a lazy Supplier API.
  • Each enabled event has a sequence allocated under the logger monitor and immutable message/field snapshot. Fields are String→String values, so Map.copyOf gives adequate defensive ownership.
  • Different concurrent log calls may invoke sinks in a different order from their allocated sequence numbers. The core does not promise cross-thread ordered output. Sinks must support the concurrency their callers permit.
  • One sink exception is counted and does not prevent later sinks from being called. JVM Errors are not caught. Logger restores thread interruption on InterruptedException while continuing its declared fan-out; BoundedSink restores interruption and stops that drain, leaving remaining queued records. Failed records are not retried.
  • Report counts completed sink calls and failed sink calls. It does not count delivered records or even queue admissions: a drop-new BoundedSink returns normally while incrementing its own dropped counter.
  • BoundedSink is an explicitly pumped queue, not an automatic asynchronous worker. write enqueues up to capacity, then drops the newest event. drain(maxEvents) has one drainer and invokes the delegate outside the queue monitor.
  • Queue capacity bounds event slots, not payload bytes. One additional event may be in flight. close stops future writes but retains already queued events for explicit drain. There is no deadline, fsync guarantee or automatic flush; a blocking delegate can block the drainer.

Worked examples

Input: Threshold INFO, three enabled events, capacity-two queued sink and one failing sibling sink.

Outcome: Two events queue, one drops; failing sibling does not stop healthy calls. Report is not a delivery receipt.

Failure isolation and admission/drop counters answer different questions.

Input: Drainer waits inside a slow delegate while a producer writes.

Outcome: Producer can enqueue because I/O runs outside queue monitor; a second drainer is rejected.

Queue ownership must not inherit arbitrary destination latency.

Your design attempt

Each language has a separate draft across all three levels. Saving is device-only and does not sync to your account. Anyone using this browser profile can load it. Loading is always explicit.

Java 21 reference code is provided for local study. Use Run program in the editor to execute your code and test driver. Program runs show output; they do not grade your design.

Solution.java
Run program · Java 21

No unsaved changes · 208/20000

Self-review

Checks apply to this visit. Self-review is not a passed submission or verified mastery.

Try before revealing

Hint 1

What exactly does a successful log call acknowledge?

Hint 2

What state must be copied before an event waits in a queue?

Hint 3

Keep destination I/O outside queue ownership, with one drainer.

Requirements and invariants

R1 — Disabled levels invoke no sinks and allocate no Event. log protects I1.

R2 — Queued events cannot observe later caller-field mutations. log protects I2.

R3 — Queue length never exceeds capacity and one drainer owns delivery. drain protects I3.

R4 — Destination I/O cannot hold the queue monitor. drain protects I3.

I1: A filtered event has zero completed/failed sink calls.

I2: An Event owns an immutable String-field snapshot taken before sink invocation.

I3: Queue depth <= capacity, at most one event is in-flight under one drainer, and slow delegate I/O runs outside queue ownership.

Derive the design

Synchronous sinks first, then make buffering a composable boundary

A logger that directly writes to one output is simple, but callers are coupled to destination latency and cannot change formatting without changing output code. The reference introduces tiny Sink and Formatter ports and a TextSink composition. BoundedSink decorates a sink with explicit admission and later drain behavior. It is intentionally a manually pumped model so tests can control scheduling without sleeps.

Alternative: A background worker can call drain, but then the design needs thread ownership, interruption, shutdown/join, deadlines and retry rules. Adding a queue alone does not implement those lifecycle semantics. A well-maintained logging library is often the practical production choice; this chapter studies the component contracts.

Define the acknowledgement before implementing buffering

Does log return mean attempted, queued, written, flushed or durable? This implementation reports completed method calls and exposes separate queue metrics. Ambiguous “success” labels are a design bug.

Capture context at the correct boundary

Construct immutable Event before fan-out. Deferred sinks must not refer to a mutable map or thread context that will be reused after the call.

Separate queue mutation from I/O

One drainer claims ownership, removes an event under the monitor, then releases it before calling the destination. Producers and close can proceed even if output is slow.

Treat shutdown as a contract, not a method name

close only stops admission here. Accepted events remain queued. A full lifecycle would explicitly own a worker, drain budget/deadline and join behavior, including what happens if the output never returns.

Responsibilities and ownership

Logger

Owns: Threshold, immutable sink list and sequence allocation

Methods: log

Creates event and isolates sink failures; no sink I/O under the logger monitor.

Event

Owns: Immutable sequence, severity, message and fields

Methods: Validated record constructor

No reference to mutable caller field maps.

Sink / Formatter / TextSink

Owns: Destination contract / serialization / bridge to output

Methods: write / format

Narrow interfaces separate representation from destination behavior.

BoundedSink

Owns: Queue, closure flag, drainer ownership and counters

Methods: write, drain, close

Drop-new policy and explicit pumping are visible behavior, not hidden durability promises.

Logger composition

Sequence allocation does not promise cross-thread output ordering.

  1. Caller + level / fields
  2. ↓ Skip disabled events

    Filter + immutable Event
  3. ↓ Invoke sinks outside logger monitor

    Sink fan-out
  4. ↓ Count ordinary failures independently

    Formatter / destination adapter
Bounded queued sink

This is a manually pumped queue. No background worker, deadline or durability is implied.

  1. write: admit or drop-new
  2. ↓ Track drops explicitly

    Queue capacity C
  3. ↓ At most one in-flight event

    Single drainer polls
  4. ↓ close stops admission; drain remains explicit

    Delegate I/O outside monitor

Data model, schema and relationships

Separate logger configuration, immutable events, destinations and per-destination delivery state. A formatted string should not become the only surviving structured evidence.

Implementation boundary: The reference filters/formats events and manually pumps a bounded queue. The optional tables describe configuration/durable spooling; no automatic workers or database log sink is implemented.

These are proposed logical schemas with field contracts, not executable database migrations. PK identifies a row, FK references an existing parent, UK is a uniqueness rule. A composite PK identifies the combination. Nullable references are marked with ?. A foreign key protects identity; cross-row limits, timing and state transitions also require the transaction rules below.

logger_config: relationship map

Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.

logger_config relationshipslogger_config 1 to log_event 0..*: interprets. log_event.config_id → logger_config.id.interprets1 parent · 0..* childrenlog_eventid [PK]config_id [FK]levellogger_configid [PK]levelversion
log_event: relationship map

Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.

log_event relationshipslog_event 1 to log_delivery 0..*: fans out. log_delivery.event_id → log_event.id.fans out1 parent · 0..* childrenlog_deliveryevent_id + sink_id [PK]statussequencelog_eventid [PK]config_id [FK]level
sink: relationship map

Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.

sink relationshipssink 1 to log_delivery 0..*: receives. log_delivery.sink_id → sink.id.receives1 parent · 0..* childrenlog_deliveryevent_id + sink_id [PK]statussequencesinkid [PK]kind

Entity definitions and field contracts

logger_config

Versioned filtering/formatting rules.

FieldType / keyMeaning and reference
idtext · PKConfig identity
levelenum · VALUEMinimum severity
versionbigint · VALUEConfig interpretation

Code mapping: Logger configuration.

  • Configuration snapshots must be coherent at event acceptance.

log_event

Immutable accepted structured event.

FieldType / keyMeaning and reference
iduuid · PKEvent identity
config_idtext · FKAcceptance rules · logger_config.id
levelenum · VALUESeverity
messagetext · VALUEPlain message
contextjson · VALUECopied structured fields
created_attimestamp · VALUEImmutable acceptance instant for history queries

Code mapping: Event record.

  • Bound message/context bytes and redact secrets before durable storage in production.

sink

Destination contract.

FieldType / keyMeaning and reference
idtext · PKSink identity
kindenum · VALUEConsole file remote etc

Code mapping: Sink interface.

  • Destination I/O occurs outside queue ownership.

log_delivery

Optional per-destination spool item.

FieldType / keyMeaning and reference
event_iduuid · PK,FKAccepted event · log_event.id
sink_idtext · PK,FKDestination · sink.id
statusenum · VALUEQueued accepted failed
sequencebigint · VALUEPer-subscription/destination order for durable spool scanning

Code mapping: Optional durable spool extension; local queue is manually pumped.

  • PK(event_id,sink_id); queue count and byte limits are separate.

Relationship cardinalities and their reasons

Parent → childMultiplicityFK / why
logger_config → log_event

interprets

1 parent per child; 0..* children per parentlog_event.config_id → logger_config.id

Events retain the filtering/formatting policy context.

log_event → log_delivery

fans out

1 parent per child; 0..* children per parentlog_delivery.event_id → log_event.id

One event may target many sinks.

sink → log_delivery

receives

1 parent per child; 0..* children per parentlog_delivery.sink_id → sink.id

Join row resolves event/sink many-to-many delivery state.

Transaction boundaries

  1. Accept event: snapshot fields and configured destinations before queue admission.
  2. Durable spool extension: event plus target delivery rows/outbox commit together; sink acknowledgements update each destination independently.

Indexes and uniqueness

  1. Index log_delivery(sink_id,status,sequence) for bounded ordered draining.
  2. Index log_event(created_at,level) only for declared query/retention needs; avoid unbounded high-cardinality indexes.

Schema traps and failure cases

  1. One failed sink must not prevent a sibling from receiving an accepted event.
  2. Queued or accepted by a sink does not prove durable remote storage; state names must match actual adapter acknowledgement.

Trace the records through one operation

  1. An INFO event below threshold creates no delivery.
  2. Accepted ERROR snapshots fields, then targets two sinks.
  3. One sink throws; the other still runs outside the queue monitor.

Method contracts

Logger.log(level,message,fields) → Report

Filter first, create immutable Event, allocate sequence, invoke every sink and count normal returns/exceptions. A normal return from a queued wrapper is not proof of delivery.

Sink.write(Event); Formatter.format(Event)

Sink is the destination behavior; Formatter maps an immutable event to text. TextSink composes them with a Consumer output port.

BoundedSink.write(Event)

Reject after close; enqueue if capacity remains; otherwise drop newest and increment dropped. It never blocks waiting for destination I/O.

drain(maxEvents)

Reject nonpositive budget or an active drainer. Remove each accepted event under the monitor, call delegate outside it, count success/failure and stop at budget or empty queue.

close(); queued/dropped/failed/delivered counters

Close is idempotent admission shutdown. Counters describe wrapper behavior. Delegate “delivered” means write returned normally, not that data reached durable disk or a remote user.

What, when, why and how

Each concept is attached to the requirement it protects, its implementation, an alternative and a test.

Narrow interfaces and composition

what
Keep serialization separate from destination writes and wrapper behavior.
when
Destinations or formatting rules vary independently.
why
A large inherited logger hierarchy creates combinations such as JsonFileAsyncLogger and TextRemoteRetryLogger.
how
Formatter produces text; TextSink sends it to a Consumer. BoundedSink wraps any Sink, while Logger fans out to a configured immutable list.

Code: Sink, Formatter, TextSink, BoundedSink · Invariants: I1, I3

Alternatives, cost and failure example

Alternative: Direct output is simpler for one tiny tool. Extract interfaces when there is a real boundary or second implementation.

Cost: Each wrapper adds an indirection and a failure contract; composition order matters.

Counterexample: Place filtering after buffering and fill the queue with disabled events.

Test: Verify DEBUG invokes no sink while enabled events still reach the healthy sibling after another fails.

Immutable event capture

what
Snapshot structured fields before deferred use.
when
The caller reuses a mutable field map after logging.
why
Queued output must reflect the log-time context, not later mutations.
how
Event copies the field map; String keys/values are immutable. The same event can be safely offered to multiple sinks.

Code: Event, log · Invariants: I2

Alternatives, cost and failure example

Alternative: For arbitrary nested objects, a shallow map copy is insufficient; serialize or define immutable value ownership.

Cost: Capturing fields allocates memory. Queue count bounds do not cap total bytes without field/message limits.

Counterexample: Caller changes tenant=A to tenant=B before drain and old event is incorrectly attributed to B.

Test: Mutate the original map after log; drained event must still show A.

Backpressure as an explicit admission policy

what
Choose what happens when producers outrun a destination.
when
A sink is slow and buffering is finite.
why
An unbounded queue trades immediate latency for eventual memory exhaustion.
how
BoundedSink drops newest at capacity and exposes dropped count. Another policy could block/reject/drop oldest, but would change caller latency and data-loss semantics.

Code: write, dropped · Invariants: I3

Alternatives, cost and failure example

Alternative: A rejecting sink can make the call fail visibly; blocking preserves some events but can stall request threads and still needs deadlines.

Cost: Capacity C admits at most C queued plus one in-flight event, each with potentially large payload.

Counterexample: Treat completedCalls as delivered count even though the bounded wrapper dropped the record.

Test: Fill capacity two with three events; check queue two and dropped one separately from Logger report.

Single drainer and lock-free destination invocation

what
Serialize queue consumption without holding the queue lock during I/O.
when
Several drainers or slow destinations can interleave.
why
Two drainers can reorder output; lock-held I/O prevents producers and close from making progress.
how
Set draining under the monitor, poll each event briefly, invoke delegate outside it, then record counters. finally releases drainer ownership even on an unexpected error.

Code: drain, close · Invariants: I3

Alternatives, cost and failure example

Alternative: One owned background worker is a natural extension, but must implement close/join and interruption explicitly.

Cost: A delegate can still block the drainer indefinitely; maxEvents bounds count, not wall-clock duration.

Counterexample: A destination blocks while synchronized queue write and shutdown wait for its monitor.

Test: Hold the delegate on a latch and prove a producer can enqueue; concurrent drain must reject.

Read and run the Java reference

Reveal complete Solution.java

Java 21. Save as Solution.java; compile with javac --release 21. The repository also contains Test.java and the lld:java verification command. This reference implements the local contract above.

Solution.java Java 21
import java.util.*;
/** Small synchronous logger with an explicitly pumped bounded async sink. No background worker. */
public final class Solution {
    private Solution() {}
    public enum Level { DEBUG, INFO, WARN, ERROR }
    public record Event(long sequence,Level level,String message,Map<String,String> fields){
        public Event{Objects.requireNonNull(level);Objects.requireNonNull(message);fields=Map.copyOf(fields);}
    }
    @FunctionalInterface public interface Sink { void write(Event event) throws Exception; }
    @FunctionalInterface public interface Formatter { String format(Event event); }
    public record Report(boolean filtered,int completedCalls,int failedCalls){}
    public static final class TextSink implements Sink {
        private final Formatter formatter;
        private final java.util.function.Consumer<String> output;
        public TextSink(Formatter formatter,java.util.function.Consumer<String> output){this.formatter=Objects.requireNonNull(formatter);this.output=Objects.requireNonNull(output);}
        public void write(Event event){output.accept(formatter.format(event));}
    }
    public static final class Logger {
        private final Level threshold;
        private final List<Sink> sinks;
        private long sequence;
        public Logger(Level threshold,List<Sink> sinks){this.threshold=Objects.requireNonNull(threshold);this.sinks=List.copyOf(sinks);}
        public Report log(Level level,String message,Map<String,String> fields){
            Objects.requireNonNull(level);if(level.ordinal()<threshold.ordinal())return new Report(true,0,0);
            final Event event;synchronized(this){event=new Event(Math.incrementExact(sequence),level,message,fields);sequence=event.sequence();}
            int delivered=0,failed=0;
            for(var sink:sinks)try{sink.write(event);delivered++;}catch(Exception ex){failed++;if(ex instanceof InterruptedException)Thread.currentThread().interrupt();}
            return new Report(false,delivered,failed);
        }
    }
    public static final class BoundedSink implements Sink {
        private final int capacity;
        private final Sink delegate;
        private final ArrayDeque<Event> queue=new ArrayDeque<>();
        private boolean closed,draining;
        private long dropped,failed,delivered;
        public BoundedSink(int capacity,Sink delegate){if(capacity<1)throw new IllegalArgumentException("capacity");this.capacity=capacity;this.delegate=Objects.requireNonNull(delegate);}
        public synchronized void write(Event event){Objects.requireNonNull(event);if(closed)throw new IllegalStateException("closed");if(queue.size()==capacity){dropped++;return;}queue.addLast(event);}
        // Single drainer. I/O runs outside the queue monitor; reentrant drain is rejected.
        public void drain(int maxEvents){
            if(maxEvents<1)throw new IllegalArgumentException("maxEvents");
            synchronized(this){if(draining)throw new IllegalStateException("already draining");draining=true;}
            try{
                for(int processed=0;processed<maxEvents;processed++){final Event event;synchronized(this){event=queue.pollFirst();}if(event==null)return;
                    try{delegate.write(event);synchronized(this){delivered++;}}catch(Exception ex){synchronized(this){failed++;}if(ex instanceof InterruptedException){Thread.currentThread().interrupt();return;}}
                }
            }finally{synchronized(this){draining=false;notifyAll();}}
        }
        /** Stops admission. Already accepted events remain queued and may be drained afterwards. */
        public synchronized void close(){closed=true;}
        public synchronized int queued(){return queue.size();}
        public synchronized long dropped(){return dropped;}
        public synchronized long failed(){return failed;}
        public synchronized long delivered(){return delivered;}
    }
}

log filters before constructing Event

Below-threshold calls do not validate unused message/field arguments in this contract. Enabled calls create a defensive snapshot and allocate sequence only after validation succeeds.

Sink fan-out catches ordinary exceptions

A failed sink increments its own count; later sinks are still attempted. InterruptedException restores the thread interruption flag. The queued drainer additionally stops on interruption, leaving unprocessed queued records. Do not log failures recursively through the same broken sink or hide fatal JVM Errors.

BoundedSink preserves short critical sections

write and poll/counters hold the monitor briefly. delegate.write occurs outside it. One drainer flag protects order and prevents reentrant drain.

close preserves queued work

The closed flag only rejects future admission. Explicit drain remains legal after close; callers must decide how long to wait and what to do with leftovers.

Complexity and limits

  • Logger fan-out costs O(F+S) for F copied fields and S sink calls, excluding arbitrary sink work. Sequence allocation is serialized, but sink calls are not globally ordered.
  • BoundedSink queue admission is O(1); drain(N) performs at most N delegate calls and O(N) queue operations. Destination cost and blocking dominate latency.
  • Queue storage is O(C×event size) plus one in-flight event and metric fields. Because message/field bytes are unbounded, fixed C is not a strict byte-memory limit.
  • TextSink formatting cost depends on Formatter and output contract. There is no fsync, file rotation, durable replay or automatic retry implementation.

Walk through the actual reference

These walkthrough fixtures are emitted only after the Java harness verifies their operations and expected state. Concurrency tests additionally exercise competing callers.

Filtering, isolation and bounded admission

Step 1 of 3: DEBUG below INFO threshold

Filtered before event allocation

Invariant: Disabled log does not invoke sinks

Read the complete trace table
ActionState after actionInvariant
DEBUG below INFO thresholdFiltered before event allocationDisabled log does not invoke sinks
Write three enabled events into capacity twoTwo queued, one dropped; failing sibling isolatedQueue depth never exceeds two
Drain then close and drain remainingAccepted events reach delegate; new writes failClose stops admission, not automatic durability

Slow destination without lock-held I/O

Step 1 of 2: Drainer enters slow delegate

One event in flight outside queue monitor

Invariant: Only one drainer owns delivery

Read the complete trace table
ActionState after actionInvariant
Drainer enters slow delegateOne event in flight outside queue monitorOnly one drainer owns delivery
Producer enqueues next eventProducer completes while destination remains blockedDestination latency does not hold queue lock

Mistakes and counterexamples

Call a queue write durable delivery

Failing case: The wrapper drops a full-queue event while returning normally.

Repair: Expose precise admission/drop/write/flush acknowledgements.

Capture a mutable field map by reference

Failing case: Tenant fields change before asynchronous output.

Repair: Snapshot immutable values at log time.

Hold the queue lock during output

Failing case: A stalled destination blocks every producer and close call.

Repair: Poll under lock and invoke destination outside it with single-drainer ownership.

Assume close flushes everything

Failing case: close sets a flag while queued records remain.

Repair: Define worker/drain/deadline/leftover semantics separately.

Engineer → Senior → Staff

Choose a deep dive

Compose a useful logger with clear contracts.

Executed: filtering, immutable events, formatter/output adapter and sibling failure isolation.

Change exercise

Add a second text formatter without changing Logger.

Worked answer guidance

Inject another Formatter into TextSink and test rendered text through a fake Consumer. The logger should not know output syntax.

Interview follow-ups

Why not a mutable global singleton?

Global configuration and state complicate test isolation and lifecycle. Inject a logger or registry with explicit ownership if global lookup is actually needed.

Does sequence imply output order?

No. Sequence is allocated under a short monitor but concurrent sink invocation can reorder events. State ordering scope explicitly.

Return to the lesson →

Provenance and scope of evidence

Original teaching material. The local contract is implemented by Solution.java and tested by Test.java on Java 21. Distributed persistence, real provider/device adapters and deployment are not claimed.

Reference verification is not independent editorial review, production deployment verification or a claim that distributed adapters are implemented.