What is Spring's TaskDecorator, and why is it needed when running work on a thread pool like ThreadPoolTaskExecutor?
answer
- Runnable decorate(Runnable) — one method
- runs on caller at submit, wrapper runs on pool thread
- capture in decorate body, restore in finally
- setTaskDecorator on ThreadPoolTaskExecutor
- pool threads reused -> must clear to avoid leaks
basics
~20 sTaskDecorator is an interface with one method, decorate(Runnable), that wraps every task before a ThreadPoolTaskExecutor runs it. You use it to carry thread-local context (like logging MDC or the logged-in user) from the calling thread onto the pool thread.
solid answer
~40 sTaskDecorator (org.springframework.core.task.TaskDecorator, since Spring 4.3) has a single method, Runnable decorate(Runnable). When you register it via ThreadPoolTaskExecutor.setTaskDecorator, Spring calls it once per submitted task, on the submitting thread, and runs the returned wrapper on a pool thread. It's needed because thread-locals — SLF4J MDC, SecurityContextHolder, RequestContextHolder, Micrometer Observation — live on the caller's thread and are NOT visible on a different pool thread. The decorator captures that state at submit time and re-installs it around the task's execution. The correct shape is: read the context inside decorate() (caller thread), then in the returned lambda set it, run the task in a try, and clear/restore it in a finally so reused pool threads don't leak state.
code
java · 24 linesimport org.springframework.core.task.TaskDecorator;
import org.slf4j.MDC;
import java.util.Map;
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 1) capture on the CALLER thread, at submit time
Map<String, String> captured = MDC.getCopyOfContextMap();
return () -> {
// runs on the POOL thread
Map<String, String> previous = MDC.getCopyOfContextMap();
if (captured != null) MDC.setContextMap(captured);
else MDC.clear();
try {
runnable.run();
} finally {
// 3) restore so the reused pool thread doesn't leak
if (previous != null) MDC.setContextMap(previous);
else MDC.clear();
}
};
}
}go deeper
Know it's one method, decorate(Runnable), used to carry MDC/user context onto pool threads.
Explain the capture-on-caller / restore-in-finally pattern and why cleanup prevents leakage.
Contrast hand-rolled decorators with Micrometer context-propagation and note it's per-executor.
Discuss uniform propagation strategy, reused-thread hygiene, and standardizing via ContextPropagatingTaskDecorator across all executors.
## The problem A **thread-local** is a variable whose value is scoped to one thread. Many Spring/Java facilities store per-request state in thread-locals: - **SLF4J MDC** (Mapped Diagnostic Context) — key/value pairs (e.g. `requestId`) that appear in every log line. - **SecurityContextHolder** — the authenticated user (default strategy `MODE_THREADLOCAL`). - **RequestContextHolder** — the current `HttpServletRequest`/request-scoped beans. - **Micrometer Observation / trace context** — the current span for distributed tracing. When you hand work to a **thread pool** (`ThreadPoolTaskExecutor`, the executor behind `@Async`), the task runs on a *different* thread than the one that submitted it. That worker thread has its own, empty (or stale, reused) thread-locals. So logs lose the `requestId`, `SecurityContextHolder.getContext()` returns an empty context, tracing spans break, etc. ## What TaskDecorator is `org.springframework.core.task.TaskDecorator` is a functional interface introduced in **Spring Framework 4.3**: ```java @FunctionalInterface public interface TaskDecorator { Runnable decorate(Runnable runnable); } ``` You register it with `ThreadPoolTaskExecutor.setTaskDecorator(...)` (also available on `SimpleAsyncTaskExecutor` and `ConcurrentTaskExecutor`). Spring invokes `decorate` for **every** task submitted to that executor. ## The critical timing detail `decorate(runnable)` is called **on the submitting (caller) thread, at submit time** — not on the worker. The `Runnable` it returns is what actually runs later on the pool thread. Therefore: 1. **Capture** the context by reading thread-locals in the body of `decorate` (caller thread). 2. In the returned lambda (worker thread) **install** the captured context, run the delegate, and in a **`finally`** block **restore/clear** it. Step 3 matters because pool threads are **reused**. If you set MDC but never clear it, the next unrelated task on that same thread inherits a stale `requestId` — a real, hard-to-debug correctness/privacy bug. ## Gotchas - Capturing inside the *returned* lambda instead of in `decorate` reads the worker thread's (wrong/empty) context. - Forgetting the `finally` cleanup leaks context between pooled tasks. - A `TaskDecorator` only affects the executor it is registered on; each executor needs its own. - It wraps `Runnable`; `Callable` submissions are adapted, so the decorator still applies. ## When to use Any time async/pooled work must keep request-scoped diagnostic or security context: correlation IDs in logs, the authenticated principal, tracing spans. In modern stacks prefer Micrometer **context-propagation** (Spring 6.1's `ContextPropagatingTaskDecorator`) so all registered thread-locals propagate uniformly instead of hand-writing one decorator per concern.
- On which thread does decorate() execute, and why does that matter?On the submitting/caller thread at submit time. That's exactly why you read (capture) the thread-locals there — they hold the caller's request context. The returned Runnable then runs on the pool thread and re-installs that captured context.
- What breaks if you omit the finally cleanup?Pool threads are reused, so the context you set stays on the thread and bleeds into the next, unrelated task — e.g. wrong requestId in logs or a stale principal — until it's overwritten. Always restore or clear in finally.
saying these in an interview costs you the question
- Thinking decorate() runs on the pool thread (it runs on the caller at submit time).
- Capturing context inside the returned Runnable instead of in decorate's body.
- Believing thread-locals are automatically shared across threads in a pool.
- Omitting finally/clear, causing context to leak between reused pool tasks.