How does Micrometer's context-propagation library (ContextSnapshot / ThreadLocalAccessor) unify context propagation, and how does Spring integrate it?
answer
- ThreadLocalAccessor SPI: getValue/setValue/reset
- ContextRegistry global; captureAll() -> ContextSnapshot
- snapshot.wrap(Runnable) restores + resets
- Spring 6.1 ContextPropagatingTaskDecorator (task.support)
- same lib Reactor uses across imperative/reactive
basics
~20 sMicrometer's context-propagation library defines ThreadLocalAccessor SPI implementations for each thread-local (MDC, tracing, security). ContextSnapshot.captureAll() grabs all registered ones at once; wrapping a Runnable restores and then clears them on the pool thread. Spring 6.1's ContextPropagatingTaskDecorator plugs this into any executor.
solid answer
~40 sThe io.micrometer:context-propagation library abstracts each thread-local behind a ThreadLocalAccessor (getValue/setValue/restore/reset) registered in a global ContextRegistry. A ContextSnapshot (via ContextSnapshotFactory) captures the values of all registered accessors — MDC (Slf4jThreadLocalAccessor), Micrometer Observation (ObservationThreadLocalAccessor), Spring Security's context, etc. — in one object. You then snapshot.wrap(runnable) (or setThreadLocals()/scope) to install them on the worker and reset afterward. Instead of one bespoke TaskDecorator per concern, you get uniform propagation. Spring Framework 6.1 ships ContextPropagatingTaskDecorator (org.springframework.core.task.support) that does exactly this — set it via ThreadPoolTaskExecutor.setTaskDecorator. It's the same bridge Reactor uses to move context across the imperative/reactive boundary. Register a ThreadLocalAccessor for any custom thread-local you also want carried.
code
java · 14 linesimport org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.context.annotation.Bean;
// Spring 6.1+: one decorator propagates MDC, Observation, SecurityContext,
// and any custom ThreadLocalAccessor registered in the ContextRegistry.
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
executor.initialize();
return executor;
}go deeper
Aware there's a library that copies logging/tracing context to async threads.
Know ContextPropagatingTaskDecorator exists and replaces hand-rolled decorators.
Explain ThreadLocalAccessor/ContextSnapshot/ContextRegistry and registering custom accessors.
Reason about one unified propagation strategy spanning executors and Reactor, and its dependency/registration requirements.
## The motivation Hand-writing a `TaskDecorator` per concern (one for MDC, one for `SecurityContext`, one for tracing) is repetitive and error-prone — each must capture-on-caller and clean-up-in-finally correctly. **Micrometer context-propagation** (`io.micrometer:context-propagation`) provides one abstraction that captures and restores *all* known thread-locals together. ## Core concepts - **`ThreadLocalAccessor<T>`** — an SPI describing how to read/write one thread-local: `key()`, `getValue()`, `setValue(T)`, `setValue()` (reset to empty), `restore(...)`. Implementations exist for common contexts: - `Slf4jThreadLocalAccessor` — MDC - `ObservationThreadLocalAccessor` — the current Micrometer `Observation` (tracing span) - Spring Security registers one for the `SecurityContext`. - **`ContextRegistry`** — a global registry (`ContextRegistry.getInstance()`) of all `ThreadLocalAccessor`s and `ContextAccessor`s. Accessors self-register via `ServiceLoader` or explicitly. - **`ContextSnapshot`** — an immutable capture of the current values of all registered accessors. Built via `ContextSnapshotFactory.builder().build().captureAll()`. - Restoring: `snapshot.wrap(Runnable)` returns a Runnable that sets the thread-locals, runs, and resets them; or `try (var scope = snapshot.setThreadLocals()) { ... }`. ## Using it directly ```java ContextSnapshotFactory factory = ContextSnapshotFactory.builder().build(); ContextSnapshot snapshot = factory.captureAll(); // caller thread executor.submit(snapshot.wrap(() -> doWork())); // restored on worker, reset after ``` ## Spring integration — the easy button Spring Framework **6.1** added `org.springframework.core.task.support.ContextPropagatingTaskDecorator`. It wraps each task with a captured `ContextSnapshot`, so registering it on an executor propagates *every* registered thread-local: ```java ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setTaskDecorator(new ContextPropagatingTaskDecorator()); executor.initialize(); ``` This is the recommended modern approach: no bespoke per-concern decorators, correct capture/reset, and consistency with how **Reactor** propagates context across the imperative↔reactive boundary (Reactor uses the same library via `contextCapture()` / `ContextSnapshot`). ## Extending to custom thread-locals If you have your own thread-local (say a `TenantHolder`), implement a `ThreadLocalAccessor` and register it, and it rides along with the same snapshot: ```java public class TenantAccessor implements ThreadLocalAccessor<String> { public Object key() { return "tenant"; } public String getValue() { return TenantHolder.get(); } public void setValue(String v) { TenantHolder.set(v); } public void setValue() { TenantHolder.clear(); } } // ContextRegistry.getInstance().registerThreadLocalAccessor(new TenantAccessor()); ``` ## Gotchas - **The capture must happen on the caller** — `ContextPropagatingTaskDecorator` captures in `decorate` (submit time). Capturing late (e.g. inside async lambdas) grabs nothing. - An accessor must be **registered** to be propagated; a plain thread-local nobody wrote an accessor for is silently ignored. - The library still relies on **reset** semantics for pool hygiene — `setValue()` (no-arg) resets after the scope, mirroring the finally-clear pattern. - Requires the `io.micrometer:context-propagation` dependency (pulled in by Spring Boot's observability/actuator stacks). ## When to use Use it whenever you need **more than one** context propagated, or want tracing/observation to survive `@Async`. For a single trivial concern a bespoke `TaskDecorator` is fine, but the Micrometer route scales and stays consistent with Reactor and Boot's observability.
- Your custom TenantId thread-local isn't propagating with ContextPropagatingTaskDecorator. Why?Micrometer only propagates thread-locals that have a registered ThreadLocalAccessor. Implement a ThreadLocalAccessor for the tenant holder and register it in the ContextRegistry; then it rides along in every ContextSnapshot.
- How does this relate to Reactor context propagation?Reactor uses the same io.micrometer:context-propagation library. contextCapture()/ContextSnapshot bridge thread-locals to Reactor's Context and back, so the same accessors carry MDC/Observation/security across both the async-pool and reactive boundaries.
saying these in an interview costs you the question
- Thinking ContextSnapshot propagates arbitrary thread-locals without a registered accessor.
- Confusing ContextPropagatingTaskDecorator (Spring 6.1) with a pre-6.1 API.
- Capturing the snapshot on the worker thread instead of at submit time.
- Assuming it works without the io.micrometer:context-propagation dependency on the classpath.