skip to content

How does Micrometer's context-propagation library (ContextSnapshot / ThreadLocalAccessor) unify context propagation, and how does Spring integrate it?

level: seniorimportance: should knowfreq 40%

answer

  1. ThreadLocalAccessor SPI: getValue/setValue/reset
  2. ContextRegistry global; captureAll() -> ContextSnapshot
  3. snapshot.wrap(Runnable) restores + resets
  4. Spring 6.1 ContextPropagatingTaskDecorator (task.support)
  5. same lib Reactor uses across imperative/reactive

basics

~20 s

Micrometer'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 s

The 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 lines
java
import 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

for a junior

Aware there's a library that copies logging/tracing context to async threads.

for a middle

Know ContextPropagatingTaskDecorator exists and replaces hand-rolled decorators.

for a senior

Explain ThreadLocalAccessor/ContextSnapshot/ContextRegistry and registering custom accessors.

for a principal

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.

context