LLD-011 · LOW LEVEL DESIGN
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.
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.
Sequence allocation does not promise cross-thread output ordering.
- Caller + level / fields
↓ Skip disabled events
Filter + immutable Event↓ Invoke sinks outside logger monitor
Sink fan-out↓ Count ordinary failures independently
Formatter / destination adapter
This is a manually pumped queue. No background worker, deadline or durability is implied.
- write: admit or drop-new
↓ Track drops explicitly
Queue capacity C↓ At most one in-flight event
Single drainer polls↓ 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.
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.
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.
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.
Entity definitions and field contracts
logger_config
Versioned filtering/formatting rules.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | text · PK | Config identity |
level | enum · VALUE | Minimum severity |
version | bigint · VALUE | Config interpretation |
Code mapping: Logger configuration.
- Configuration snapshots must be coherent at event acceptance.
log_event
Immutable accepted structured event.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Event identity |
config_id | text · FK | Acceptance rules · logger_config.id |
level | enum · VALUE | Severity |
message | text · VALUE | Plain message |
context | json · VALUE | Copied structured fields |
created_at | timestamp · VALUE | Immutable acceptance instant for history queries |
Code mapping: Event record.
- Bound message/context bytes and redact secrets before durable storage in production.
sink
Destination contract.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | text · PK | Sink identity |
kind | enum · VALUE | Console file remote etc |
Code mapping: Sink interface.
- Destination I/O occurs outside queue ownership.
log_delivery
Optional per-destination spool item.
| Field | Type / key | Meaning and reference |
|---|---|---|
event_id | uuid · PK,FK | Accepted event · log_event.id |
sink_id | text · PK,FK | Destination · sink.id |
status | enum · VALUE | Queued accepted failed |
sequence | bigint · VALUE | Per-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 → child | Multiplicity | FK / why |
|---|---|---|
| logger_config → log_event interprets | 1 parent per child; 0..* children per parent | log_event.config_id → logger_config.idEvents retain the filtering/formatting policy context. |
| log_event → log_delivery fans out | 1 parent per child; 0..* children per parent | log_delivery.event_id → log_event.idOne event may target many sinks. |
| sink → log_delivery receives | 1 parent per child; 0..* children per parent | log_delivery.sink_id → sink.idJoin row resolves event/sink many-to-many delivery state. |
Transaction boundaries
- Accept event: snapshot fields and configured destinations before queue admission.
- Durable spool extension: event plus target delivery rows/outbox commit together; sink acknowledgements update each destination independently.
Indexes and uniqueness
- Index log_delivery(sink_id,status,sequence) for bounded ordered draining.
- Index log_event(created_at,level) only for declared query/retention needs; avoid unbounded high-cardinality indexes.
Schema traps and failure cases
- One failed sink must not prevent a sibling from receiving an accepted event.
- 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
- An INFO event below threshold creates no delivery.
- Accepted ERROR snapshots fields, then targets two sinks.
- 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.
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
| Action | State after action | Invariant |
|---|---|---|
| DEBUG below INFO threshold | Filtered before event allocation | Disabled log does not invoke sinks |
| Write three enabled events into capacity two | Two queued, one dropped; failing sibling isolated | Queue depth never exceeds two |
| Drain then close and drain remaining | Accepted events reach delegate; new writes fail | Close 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
| Action | State after action | Invariant |
|---|---|---|
| Drainer enters slow delegate | One event in flight outside queue monitor | Only one drainer owns delivery |
| Producer enqueues next event | Producer completes while destination remains blocked | Destination 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
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.
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.
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.
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.