Skip to main content

Replacing ThreadLocal with ScopedValue for Request IDs (Java 25)

Updated 18 min readMarkdown
Part of seriesJava Concurrency & Virtual Threads
Replacing ThreadLocal with ScopedValue for Request IDs (Java 25)

Here's a log line from the demo in this post. Request #3 came in without an X-Request-Id header, yet it was logged with the ID of a different request:

http-nio-1     requestId=req-A    handling request #1
http-nio-1     requestId=req-B    handling request #2
http-nio-1     requestId=req-B    request failed: downstream timeout
http-nio-1     requestId=req-B    handling request #3

Request B threw before the filter reached MDC.remove("requestId"). MDC stores its values in a ThreadLocal, the worker thread went back to the pool still holding req-B, and the next request on that thread picked it up. Now picture someone searching the logs for req-B during an incident.

Java 25 finalized ScopedValue (JEP 506), and request IDs are exactly what it was built for. Most articles stop at ScopedValue.where(...).run(...), though, and leave out the part that matters in a real service: your logging still reads MDC, and MDC is still a ThreadLocal. So in this post I migrate a real request-ID / MDC setup step by step: the leak, child threads, the ScopedValue version, fan-out with StructuredTaskScope, and the bridge into Logback. All the output below is from running the code on JDK 25.0.4 (and JDK 21.0.12 for one comparison).

The short version

  • Keep one ScopedValue<String> REQUEST_ID, bind it once in the filter with ScopedValue.where(REQUEST_ID, id).run(...), and read it anywhere with REQUEST_ID.get(). When run() returns or throws, it's unbound. There's nothing to remove().
  • MDC doesn't know about it. SLF4J delegates MDC to Logback, and Logback's LogbackMDCAdapter is two ThreadLocal maps. Copy the value into MDC at the boundary, and restore the old MDC value in finally.
  • Child threads: only StructuredTaskScope.fork inherits scoped values, and in Java 25 StructuredTaskScope is still a preview API (JEP 505). Executors, Thread.ofVirtual().start, and CompletableFuture all showed the value as unbound in my runs. Capture the ID and bind it again inside the task.
  • Even forked subtasks log requestId=NONE unless you bridge MDC inside them too, or use Logback's new ScopedValue-based MDC (logback-scoped-mdc, a 1.0.0 release candidate right now).
  • If a tutorial uses ScopedValue.runWhere(...) or callWhere(...), it was written for an old preview. Those methods were removed in JDK 24 (JEP 487) and don't compile on 25.

ThreadLocal vs ScopedValue request ID flow

Setup

What Value
JDK OpenJDK 25.0.4.1 (one comparison on OpenJDK 21.0.12.1)
Logging SLF4J 2.0.19, Logback 1.6.6, and logback-scoped-mdc 1.0.0-rc0, all from Maven Central
Build plain javac. Only the StructuredTaskScope demos need --enable-preview
Machine 8-core Linux box

Every demo prints the thread name and Logback's %X{requestId}, so you can see what the log encoder sees:

<configuration>
  <!-- %X{requestId} reads SLF4J/Logback MDC, which is ThreadLocal-backed -->
  <appender name="OUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>%-14thread requestId=%-8X{requestId:-NONE} %msg%n</pattern></encoder>
  </appender>
  <root level="INFO"><appender-ref ref="OUT"/></root>
</configuration>

Step 1: reproduce the leak

This is the filter I see most often in real code. It puts the ID only when the header is present, and it doesn't use try/finally:

static final ExecutorService workers = Executors.newFixedThreadPool(1, r -> new Thread(r, "http-nio-1"));

/** Buggy filter: no try/finally, and only sets MDC when the header is present. */
static void buggyFilter(String headerRequestId, Runnable handler) {
    if (headerRequestId != null) {
        MDC.put("requestId", headerRequestId);
    }
    handler.run();               // if this throws, the line below never runs
    MDC.remove("requestId");
}

public static void main(String[] args) throws Exception {
    submit("req-A", false);   // fine: put ... remove
    submit("req-B", true);    // throws before remove(): req-B stays on the thread
    submit(null, false);      // no header: logs with req-B, a request it has nothing to do with
    submit("req-D", false);   // overwrites, so the leak "heals" and is hard to spot later
    submit(null, false);
}

(submit runs the filter on the one pool thread and logs any exception with log.warn("request failed: {}", ...). The pool has a single thread so the reuse happens every time, the way it would on a busy Tomcat worker.)

http-nio-1     requestId=req-A    handling request #1
http-nio-1     requestId=req-B    handling request #2
http-nio-1     requestId=req-B    request failed: downstream timeout
http-nio-1     requestId=req-B    handling request #3
http-nio-1     requestId=req-D    handling request #4
http-nio-1     requestId=NONE     handling request #5

Request #3 is logged as req-B. Then request #4 overwrites the value and removes it normally, so request #5 looks fine. That's why this bug is so hard to find in production: it only shows up after a failure, it only affects requests without the header, and the next clean request quietly fixes it.

The Logback MDC manual explains why: "The MDC manages contextual information on a per thread basis", and "a server that recycles threads might lead to false information contained in the MDC". The usual fix is try/finally. That works, but only as long as everyone remembers it on every code path, forever. JEP 506 calls this "unbounded lifetime" and lists it as one of three design flaws of thread-local variables.

Step 2: child threads don't see it (and InheritableThreadLocal is a trap)

The next problem shows up when a request fans out. I set the same value in a ThreadLocal and an InheritableThreadLocal, then read both from different kinds of child threads:

static final ThreadLocal<String> TL = new ThreadLocal<>();
static final InheritableThreadLocal<String> ITL = new InheritableThreadLocal<>();

static String show() {
    return "TL=" + TL.get() + "  ITL=" + ITL.get();
}

public static void main(String[] args) throws Exception {
    TL.set("req-1");
    ITL.set("req-1");
    System.out.println("request thread:            " + show());

    Thread vt = Thread.ofVirtual().start(() -> System.out.println("new virtual thread:        " + show()));
    vt.join();

    ExecutorService pool = Executors.newFixedThreadPool(1);
    pool.submit(() -> System.out.println("pool task (pool made now): " + show())).get();

    // Next request on the same "request thread"; the pool thread already exists
    TL.set("req-2");
    ITL.set("req-2");
    System.out.println("request thread, next req:  " + show());
    pool.submit(() -> System.out.println("pool task, next req:       " + show())).get();
    pool.shutdown();

    try (var vexec = Executors.newVirtualThreadPerTaskExecutor()) {
        vexec.submit(() -> System.out.println("virtual-per-task executor: " + show())).get();
    }
    // ... then measureInheritCost(), below
}
request thread:            TL=req-1  ITL=req-1
new virtual thread:        TL=null  ITL=req-1
pool task (pool made now): TL=null  ITL=req-1
request thread, next req:  TL=req-2  ITL=req-2
pool task, next req:       TL=null  ITL=req-1
virtual-per-task executor: TL=null  ITL=req-2
  • A plain ThreadLocal is null in every child. That's the "MDC is empty in my @Async method" bug.
  • InheritableThreadLocal is copied when the thread is created. A new virtual thread gets the right value. A pool thread gets the value from whoever caused the pool to create that thread, and keeps it. On the second request, the pool task still says req-1. So switching MDC to an inheritable map isn't a fix for pools. It's a second version of the leak.

The copy also costs memory. Here's how many bytes the parent thread allocated to create (not start) one virtual thread, with 0 to 32 inheritable thread-locals set. The copy happens in Thread's constructor, so the parent's com.sun.management.ThreadMXBean.getCurrentThreadAllocatedBytes() picks it up. Each row is 100,000 Thread.ofVirtual().unstarted(...) calls, averaged over 5 rounds after one warm-up round:

bytes allocated per Thread.ofVirtual().unstarted(..), 100000 threads, avg of 5 rounds:
   0 InheritableThreadLocals set:  312 bytes/thread
   1 InheritableThreadLocals set:  448 bytes/thread
   8 InheritableThreadLocals set:  672 bytes/thread
  32 InheritableThreadLocals set: 1632 bytes/thread

I ran it in five separate JVMs. Four gave exactly these numbers. One gave 411 / 632 / 1,592 for 1 / 8 / 32, and the 312-byte baseline was the same every time. These bytes are the copied map, not deep copies of your values. They're paid on every thread creation, though, and with one virtual thread per task, that's every task. (If you have to keep inheritable thread-locals, Thread.ofVirtual().inheritInheritableThreadLocals(false) turns the copy off for threads you create yourself.) For comparison, the ScopedValue Javadoc says inheritance into StructuredTaskScope threads is "in essence, no more than copying a pointer". I didn't measure that side, so treat it as the JDK's claim, not my number.

Step 3: the ScopedValue version

ScopedValue has no set() and no remove(). You bind a value for the duration of a run (or call), and the binding ends when that method returns or throws. This file uses only final APIs, so no preview flag is needed on 25. Here's the core of it, with the print statements for steps 3, 5 and 6 trimmed:

static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static final ScopedValue<String> TENANT = ScopedValue.newInstance();

// 1. Outside any scope
System.out.println("1. isBound=" + REQUEST_ID.isBound() + "  orElse=" + REQUEST_ID.orElse("no-request"));
try {
    REQUEST_ID.get();
} catch (NoSuchElementException e) {
    System.out.println("   get() -> " + e);
}

// 2. Bind, read deep in the call stack, rebind for a nested call
ScopedValue.where(REQUEST_ID, "req-42").where(TENANT, "acme").run(() -> {
    service();                                    // service() -> repository() -> REQUEST_ID.get()
    ScopedValue.where(REQUEST_ID, "req-42/retry-1").run(ScopedValueBasics::service);
    System.out.println("   back in outer scope: " + REQUEST_ID.get());
});

// 3. call() returns a value
String body = ScopedValue.where(REQUEST_ID, "req-43").call(() -> "response for " + REQUEST_ID.get());

// 4. The binding is gone after the scope, even if the scope threw
try {
    ScopedValue.where(REQUEST_ID, "req-44").run(() -> {
        throw new IllegalStateException("handler blew up");
    });
} catch (IllegalStateException e) {
    System.out.println("4. after a throwing scope: isBound=" + REQUEST_ID.isBound());
}

// 5. Gotcha: a missing header becomes a null binding
String header = null;
ScopedValue.where(REQUEST_ID, header).run(() -> ...);

// 6. orElse(null)
REQUEST_ID.orElse(null);

Output on JDK 25:

$ java -cp out25 ScopedValueBasics
java 25.0.4.1
1. isBound=false  orElse=no-request
   get() -> java.util.NoSuchElementException: ScopedValue not bound
2. repository sees requestId=req-42 tenant=acme
2. repository sees requestId=req-42/retry-1 tenant=acme
   back in outer scope: req-42
3. call() returned: response for req-43
4. after a throwing scope: isBound=false (handler blew up)
5. bound to null: isBound=true get=null orElse=null
6. orElse(null) -> java.lang.NullPointerException

The same file compiled with --release 21 --enable-preview and run on JDK 21 gives the same lines 1 to 5 (except that the exception on line 1 has no message), and then:

6. orElse(null) = null

What I'd point out to a teammate:

  • Rebinding is shadowing, not mutation. The retry call saw req-42/retry-1, and the outer code saw req-42 again afterwards. A callee can't change what its caller sees.
  • Line 4 is the fix for the Step 1 bug. A throwing handler can't leave the value behind.
  • Line 5 is a new trap. where(key, null) is allowed (the Javadoc says the value "can be null"), and then isBound() is true and get() returns null. So don't bind a raw header. Generate an ID when it's missing.
  • Line 6 changed in 25. JEP 506's only API change at finalization: "ScopedValue.orElse method no longer accepts null as its argument". Code written against the JDK 21 preview that calls orElse(null) now throws.

And the stale-tutorial check. runWhere compiles on 21 with --enable-preview, but on 25:

$ javac --release 25 -d out25 StaleApi.java
StaleApi.java:6: error: cannot find symbol
        ScopedValue.runWhere(REQUEST_ID, "req-1", () -> System.out.println(REQUEST_ID.get()));
                   ^
  symbol:   method runWhere(ScopedValue<String>,String,()->System[...]et()))
  location: class ScopedValue
1 error

Step 4: child tasks (StructuredTaskScope is preview in 25)

Preview API. StructuredTaskScope is in its fifth preview in JDK 25 (JEP 505). Compile and run with --enable-preview, and expect the API to change in later JDKs. ScopedValue itself is final.

ScopedValue.where(REQUEST_ID, "req-7").call(() -> {
    // 1. StructuredTaskScope.fork: inherited, no copying
    try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
            cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("fork-", 1).factory()))) {
        var a = scope.fork(() -> Thread.currentThread().getName() + " sees " + seen());
        var b = scope.fork(() -> Thread.currentThread().getName() + " sees " + seen());
        scope.join();
    }

    // 2. Everything unstructured: not inherited
    try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
        exec.submit(ChildTaskDemo::seen).get();
    }
    Thread.ofVirtual().start(...);
    CompletableFuture.supplyAsync(ChildTaskDemo::seen).get();

    // 3. Fix for an executor: capture the value, re-bind inside the task
    String id = REQUEST_ID.get();
    try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
        exec.submit(() -> ScopedValue.where(REQUEST_ID, id).call(ChildTaskDemo::seen)).get();
    }

    // 4. Bindings are captured when the scope is OPENED. Re-binding then forking is rejected.
    try (var scope = StructuredTaskScope.open()) {
        ScopedValue.where(REQUEST_ID, "req-7/child").run(() -> {
            try {
                scope.fork(ChildTaskDemo::seen);
            } catch (StructureViolationException e) {
                System.out.println("fork after re-bind: " + e);
            }
        });
        scope.join();
    }
    return null;
});

(This is trimmed: the println calls are left out. seen() returns REQUEST_ID.get(), or (unbound) if isBound() is false.)

$ java --enable-preview -cp out25p:out25 ChildTaskDemo
request thread:                 req-7
StructuredTaskScope.fork:       fork-1 sees req-7, fork-2 sees req-7
newVirtualThreadPerTaskExecutor: (unbound)
Thread.ofVirtual().start:        (unbound)
CompletableFuture.supplyAsync:   (unbound)
executor + re-bind in task:      req-7
fork after re-bind:              java.util.concurrent.StructureViolationException: Scoped value bindings have changed
after the scope:                 (unbound)

This is the part that matters most when you migrate:

  1. Only fork inherits. JEP 506 explains why: the scope's join() guarantees the children finish before run() returns, so the binding can't outlive its scope. Plain threads, executors, and the common pool behind CompletableFuture can't promise that, so they get nothing. JEP 506 says the same about ForkJoinPool. If your code used to depend on InheritableThreadLocal for new Thread(...), moving to ScopedValue makes that value disappear. Check every thread hop.
  2. Executors: pass the value, re-bind in the task. ScopedValue.where(REQUEST_ID, id).call(...) inside the task is two lines. A small Contexts.wrap(Callable) helper can do it at every submit.
  3. Open the scope inside the binding you want children to see. The Javadoc says bindings "are captured when creating a StructuredTaskScope". If you re-bind after open() and then fork, you get StructureViolationException. That's the JDK enforcing the structure, not a bug.

Step 5: bridge to MDC at the boundary

Your log pattern still says %X{requestId}, and SLF4J's MDC delegates to the underlying framework's MDC. In Logback 1.6.6 that's LogbackMDCAdapter, and javap shows it plainly:

public class ch.qos.logback.classic.util.LogbackMDCAdapter implements org.slf4j.spi.MDCAdapter {
  final java.lang.ThreadLocal<java.util.Map<java.lang.String, java.lang.String>> readWriteThreadLocalMap;
  final java.lang.ThreadLocal<java.util.Map<java.lang.String, java.lang.String>> readOnlyThreadLocalMap;

So the plan is: the ScopedValue is the source of truth, and MDC becomes a copy that lives only as long as the scope. One class owns both:

public final class RequestContext {
    private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
    private RequestContext() {}

    /** Called once, at the boundary (servlet filter, message listener, scheduled job). */
    public static void runRequest(String headerValue, Runnable handler) {
        ScopedValue.where(REQUEST_ID, idFrom(headerValue)).run(() -> withMdc(handler));
    }

    /** Same, for code that returns a value or throws checked exceptions (a servlet filter chain). */
    public static <T> T callRequest(String headerValue, Callable<T> handler) throws Exception {
        return ScopedValue.where(REQUEST_ID, idFrom(headerValue)).call(() -> withMdc(handler).call());
    }

    private static String idFrom(String headerValue) {
        return (headerValue == null || headerValue.isBlank())
                ? "gen-" + UUID.randomUUID().toString().substring(0, 4)   // never bind null
                : headerValue;
    }

    public static String requestId() {
        return REQUEST_ID.orElse("none");
    }

    /**
     * The MDC bridge. Copies the scoped value into Logback's ThreadLocal MDC for the
     * duration of 'work' on THIS thread, then restores whatever was there before.
     */
    public static void withMdc(Runnable work) {
        String previous = MDC.get("requestId");
        MDC.put("requestId", REQUEST_ID.get());
        try {
            work.run();
        } finally {
            if (previous == null) MDC.remove("requestId");
            else MDC.put("requestId", previous);
        }
    }

    /** Same bridge for a value-returning subtask (for StructuredTaskScope.fork). */
    public static <T> Callable<T> withMdc(Callable<T> work) {
        return () -> {
            String previous = MDC.get("requestId");
            MDC.put("requestId", REQUEST_ID.get());
            try {
                return work.call();
            } finally {
                if (previous == null) MDC.remove("requestId");
                else MDC.put("requestId", previous);
            }
        };
    }
}

Two details: REQUEST_ID is private (the JEP calls a scoped value a "capability", so only this class can bind it), and the bridge restores the previous MDC value instead of just removing it, in case an outer filter or tracing agent put one there.

In a servlet app the boundary is a filter. This one compiles against jakarta.servlet-api 6.1.0. I didn't run it in a container, so the runnable demo below drives RequestContext directly:

public class RequestIdFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String header = ((HttpServletRequest) req).getHeader("X-Request-Id");
        try {
            RequestContext.callRequest(header, () -> {
                ((HttpServletResponse) res).setHeader("X-Request-Id", RequestContext.requestId());
                chain.doFilter(req, res);
                return null;
            });
        } catch (IOException | ServletException | RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new ServletException(e);
        }
    }
}

Now the same request sequence as Step 1, on the same one-thread pool. Request #3 also fans out to two subtasks: one forked as-is, one wrapped in RequestContext.withMdc(...):

static void fanOut() throws InterruptedException {
    try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
            cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("fork-", 1).factory()))) {
        scope.fork(() -> {
            log.info("price lookup, no bridge  (RequestContext.requestId()={})", RequestContext.requestId());
            return 1;
        });
        scope.fork(RequestContext.withMdc(() -> {
            log.info("stock lookup, withMdc    (RequestContext.requestId()={})", RequestContext.requestId());
            return 2;
        }));
        scope.join();
    }
}
$ java --enable-preview -Dlogback.configurationFile=logback.xml -cp "out25p:out25:lib/*" MdcBridgeDemo
http-nio-1     requestId=req-A    handling request #1 (RequestContext.requestId()=req-A)
http-nio-1     requestId=req-B    handling request #2 (RequestContext.requestId()=req-B)
http-nio-1     requestId=NONE     request failed: downstream timeout
http-nio-1     requestId=gen-17dd handling request #3 (RequestContext.requestId()=gen-17dd)
fork-1         requestId=NONE     price lookup, no bridge  (RequestContext.requestId()=gen-17dd)
fork-2         requestId=gen-17dd stock lookup, withMdc    (RequestContext.requestId()=gen-17dd)
http-nio-1     requestId=NONE     pool thread between requests, MDC map = {}

Line by line:

  • The leak is gone. Request #3 has no header and got its own generated ID, not req-B. Between requests the pool thread's MDC is {}.
  • fork-1 is the trap nobody mentions. The scoped value reached the subtask (RequestContext.requestId()=gen-17dd), but Logback's %X printed NONE, because MDC is a ThreadLocal and the forked virtual thread has its own, empty one. The Logback manual warns that "a child thread does not automatically inherit a copy of the mapped diagnostic context of its parent". So you need the bridge in every subtask, not just in the filter.
  • The failure line has no ID. In this demo the catch that logs "request failed" sits outside runRequest, so by then the scope has ended. That's correct behaviour, and it's also a reminder: log errors inside the boundary (for example in an exception handler that runs inside the filter chain), or that log line loses its ID.

Option: let Logback read the ScopedValue directly

QOS.ch (the Logback maintainers) publishes logback-scoped-mdc, a ScopedValue-backed MDC with a %scopedContext pattern converter. The only version on Maven Central today is 1.0.0-rc0 (a release candidate), so I'd try it but not standardize on it yet. Config and code:

<conversionRule conversionWord="scopedContext" class="ch.qos.logback.classic.scoped.ScopedMDCConverter"/>
<!-- pattern: %-14thread requestId=%-8scopedContext{requestId:-NONE} %msg%n -->
ScopedMDC.put("requestId", "req-S").run(() -> {
    log.info("request thread");
    try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
            cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("fork-", 1).factory()))) {
        scope.fork(() -> { log.info("price lookup"); return 1; });
        scope.fork(() -> { log.info("stock lookup"); return 2; });
        scope.join();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});
log.info("after the scope");
main           requestId=req-S    request thread
fork-1         requestId=req-S    price lookup
fork-2         requestId=req-S    stock lookup
main           requestId=NONE     after the scope

Both subtasks got the ID with no bridge, and it was gone after the scope. The cost: you change %X{...} to %scopedContext{...} in your patterns, and anything else that reads the classic MDC (some appenders and JSON encoders do) won't see these values. I haven't checked how specific encoders behave, so test yours.

When to keep ThreadLocal

JEP 506 says outright that deprecating ThreadLocal is a non-goal, and that migration only fits "one-way transmission of unchanging data". A request ID is the textbook case. These aren't:

  • Two-way state, where a callee writes something for the caller to read later. A scoped value can't do this, by design.
  • Per-thread caches of expensive, non-thread-safe objects. The JEP's example is SimpleDateFormat, though it points out that DateTimeFormatter in a static final field is the better fix today.
  • Libraries that read their own ThreadLocal (MDC, security-context holders, some tracing agents). You don't migrate those. You bridge at the boundary, as in Step 5.

Also, the Javadoc says scoped values "are designed to be used in fairly small numbers", because each thread has only a small cache for them. One RequestContext record holding the ID, tenant, and user is better than five separate scoped values.

On virtual threads specifically: JEP 506 notes that a short-lived virtual thread clears its thread-locals when it ends, so the pool leak from Step 1 mostly belongs to platform-thread pools. On a platform-thread Tomcat pool it's real, and on virtual threads you still have the inheritance and fan-out problems from Steps 2 and 4. (For how virtual threads behave under load, see Virtual Thread Pinning in Java 25 and Spring Boot Virtual Threads and HikariCP. If virtual threads are new to you, start with Mastering Concurrency in Java.)

Migration checklist

  • Find every MDC.put("requestId", ...), ThreadLocal<String>, and InheritableThreadLocal that carries request context.
  • Create one owner class with a private static final ScopedValue. Bind it in one place: the first filter, or the message listener.
  • Never bind a raw header. where(key, null) gives you isBound() == true and a null value. Generate an ID instead.
  • Replace orElse(null) (it throws on 25) with a real default or orElseThrow(...).
  • Replace runWhere/callWhere from old tutorials with where(...).run(...) and where(...).call(...).
  • Bridge into MDC at the boundary with save-and-restore in finally. Bridge again inside each forked subtask, or try logback-scoped-mdc.
  • Audit every thread hop. Executors, Thread.ofVirtual().start, and CompletableFuture didn't inherit in my run, and Spring's @Async runs on an executor, so treat it the same way. Capture the value and re-bind it in the task.
  • Open StructuredTaskScope inside the binding (preview in 25: --enable-preview).
  • Log failures inside the scope, or the error line loses its ID.
  • Add a test that runs a failing request followed by one without a header on a single-thread pool, and asserts the second one doesn't log the first one's ID.

No comments yet. Be the first to comment.