skip to content

Walk through implementing an MDC-propagating TaskDecorator correctly. What are the common bugs?

level: middleimportance: should knowfreq 45%

answer

  1. MDC = SLF4J per-thread key/value (%X{requestId})
  2. getCopyOfContextMap() in decorate body (caller)
  3. setContextMap in try, clear/restore in finally
  4. null map -> setContextMap(null) throws, so clear()
  5. reused thread leaks requestId without cleanup

basics

~20 s

In decorate(), call MDC.getCopyOfContextMap() on the caller thread to snapshot the log context. In the returned Runnable, MDC.setContextMap(copy), run the task, and in a finally MDC.clear() (or restore the previous map). Common bugs: capturing on the pool thread, and skipping cleanup.

solid answer

~40 s

MDC (Mapped Diagnostic Context) is SLF4J's per-thread key/value map (e.g. requestId) woven into log lines. Because it's thread-local, pool threads lose it. Correct decorator: in decorate() snapshot with MDC.getCopyOfContextMap() on the caller; the returned lambda saves the worker's previous map, calls setContextMap(copy) (or clear() if the copy is null), runs the delegate in try, and in finally restores previous or clears. Bugs: (1) calling getCopyOfContextMap() inside the returned lambda captures the pool thread's empty/stale map; (2) no finally means the reused pool thread keeps a stale requestId across tasks; (3) getCopyOfContextMap() can return null when the caller's MDC is empty — you must handle null, since setContextMap(null) throws; (4) forgetting that each executor needs the decorator registered separately.

code

java · 13 lines
java
// Registering the MDC decorator on a dedicated executor
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.context.annotation.Bean;

@Bean("mdcExecutor")
public ThreadPoolTaskExecutor mdcExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setTaskDecorator(new MdcTaskDecorator());
    executor.setThreadNamePrefix("mdc-");
    executor.initialize();
    return executor;
}

go deeper

for a junior

Know MDC is per-thread log context that needs copying to pool threads.

for a middle

Reproduce the capture/set/finally-clear pattern and the null-map pitfall.

for a senior

Recommend Micrometer's Slf4jThreadLocalAccessor over hand-rolled decorators when multiple contexts are involved.

for a principal

Standardize propagation across executors and justify restore-vs-clear semantics for nested submissions.

## What MDC is **MDC (Mapped Diagnostic Context)** is an SLF4J/Logback facility: a per-thread `Map<String,String>` you populate (typically in a servlet filter) with correlation data like `requestId`, `userId`, `tenant`. A log pattern such as `%X{requestId}` then prints it on every line for that request. Because MDC is stored in a **thread-local**, it evaporates when work moves to a pool thread. ## The correct implementation, step by step ```java public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { // (A) CALLER thread, submit time: snapshot the request's MDC Map<String, String> captured = MDC.getCopyOfContextMap(); return () -> { // (B) POOL thread: save what's currently there Map<String, String> previous = MDC.getCopyOfContextMap(); if (captured != null) { MDC.setContextMap(captured); } else { MDC.clear(); } try { runnable.run(); } finally { // (C) restore the pool thread's prior state if (previous != null) { MDC.setContextMap(previous); } else { MDC.clear(); } } }; } } ``` Register once per executor: ```java ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setTaskDecorator(new MdcTaskDecorator()); executor.initialize(); ``` ## Common bugs 1. **Capturing on the wrong thread.** If you call `MDC.getCopyOfContextMap()` *inside* the returned lambda, you snapshot the **pool** thread's context (empty or a leftover from a previous task) — the whole point is lost. Capture in the `decorate` body. 2. **No cleanup.** Pool threads are **reused**. Without the `finally` restore/clear, task N's `requestId` leaks into task N+1's logs — misleading and a data-hygiene issue. 3. **Null handling.** `MDC.getCopyOfContextMap()` returns **null** when the MDC is empty. `MDC.setContextMap(null)` throws `NullPointerException`, so branch on null and `clear()` instead. 4. **Per-executor registration.** A decorator only affects the executor it's set on; if `@Async("otherExecutor")` uses a different bean, it needs its own decorator. 5. **Restore vs. blind clear.** Blindly `clear()`-ing in finally is usually fine for a dedicated pool, but restoring `previous` is safer if the pool thread might legitimately carry context (e.g., nested submissions). ## When to prefer the framework If you also need SecurityContext, tracing, or Observation, don't stack hand-written decorators — use **Micrometer context-propagation** with a registered `Slf4jThreadLocalAccessor` (Micrometer provides one), or Spring 6.1's `ContextPropagatingTaskDecorator`, which handles MDC and every other registered thread-local in one place with correct capture/restore semantics.

  • Why does MDC.getCopyOfContextMap() sometimes return null, and how do you handle it?
    It returns null when the current thread's MDC is empty. Since MDC.setContextMap(null) throws NPE, branch on null: if the captured map is null, call MDC.clear() instead of setContextMap.

saying these in an interview costs you the question

  • Calling getCopyOfContextMap() inside the returned lambda (captures the pool thread).
  • Assuming MDC propagates automatically to @Async threads.
  • Passing a possibly-null map straight to setContextMap without a null check.
  • Skipping finally cleanup on a reused pool thread.

context