From e22b0c67b3f6ac2a416e592729a34a3aa91f36f3 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Thu, 9 Jul 2026 10:13:12 +0200 Subject: [PATCH 1/7] Reduce virtual-thread context-propagation overhead on park/unpark --- .../java/lang/VirtualThreadState.java | 40 +- .../ddprof/DatadogProfilingIntegration.java | 5 + .../jdk21/VirtualThreadInstrumentation.java | 10 +- .../jdk21/VirtualThreadLifeCycleTest.java | 53 ++ .../core/VirtualThreadContextBenchmark.java | 185 +++++ .../java/datadog/trace/core/CoreTracer.java | 10 + .../core/scopemanager/ContinuableScope.java | 17 + .../scopemanager/ContinuableScopeManager.java | 31 + .../core/CoreTracerCarrierRebindTest.java | 44 + .../CarrierProfilerRebindTest.java | 123 +++ ...tual-thread-instrumentation-performance.md | 754 ++++++++++++++++++ .../instrumentation/api/AgentTracer.java | 6 + .../api/ProfilingContextIntegration.java | 10 + .../api/ProfilingContextIntegrationTest.java | 39 + 14 files changed, 1302 insertions(+), 25 deletions(-) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/VirtualThreadContextBenchmark.java create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java create mode 100644 docs/superpowers/plans/2026-07-08-virtual-thread-instrumentation-performance.md create mode 100644 internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java index 0a1eec7573c..feb44aa3535 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java @@ -2,42 +2,40 @@ import datadog.context.Context; import datadog.trace.bootstrap.instrumentation.api.AgentScope.Continuation; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; /** - * This class holds the saved context and scope continuation for a virtual thread. - * - *

Used by java-lang-21.0 {@code VirtualThreadInstrumentation} to swap the entire scope stack on - * mount/unmount. + * The virtual thread's scope stack lives in a virtual-thread-aware {@code ThreadLocal}, so it is + * seeded once on the first mount and then follows the thread across park/unpark and carrier + * migration on its own. ddprof's profiler context is keyed by carrier thread instead, so it is + * re-bound on mount and cleared on unmount when a carrier-bound profiling integration is active. */ public final class VirtualThreadState { - /** The virtual thread's saved context (scope stack snapshot). */ - private Context context; - - /** Prevents the enclosing context scope from completing before the virtual thread finishes. */ + private Context seedContext; + // Keeps the enclosing trace alive until the virtual thread finishes. private final Continuation continuation; + private boolean seeded; - /** The carrier thread's saved context, set between mount and unmount. */ - private Context previousContext; - - public VirtualThreadState(Context context, Continuation continuation) { - this.context = context; + public VirtualThreadState(Context seedContext, Continuation continuation) { + this.seedContext = seedContext; this.continuation = continuation; } - /** Called on mount: swaps the virtual thread's context into the carrier thread. */ public void onMount() { - this.previousContext = this.context.swap(); + if (!seeded) { + // First mount also applies the profiler context to the carrier. + seedContext.swap(); + seeded = true; + seedContext = null; + } else { + AgentTracer.get().rebindProfilingContextToCarrier(); + } } - /** Called on unmount: restores the carrier thread's original context. */ public void onUnmount() { - if (this.previousContext != null) { - this.context = this.previousContext.swap(); - this.previousContext = null; - } + AgentTracer.get().unbindProfilingContextFromCarrier(); } - /** Called on termination: releases the trace continuation. */ public void onTerminate() { if (this.continuation != null) { this.continuation.cancel(); diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java index 65462a46bce..0c7942ae957 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java @@ -78,6 +78,11 @@ public String name() { return "ddprof"; } + @Override + public boolean isCarrierThreadBound() { + return true; + } + public void clearContext() { DDPROF.clearSpanContext(); DDPROF.clearContextValue(SPAN_NAME_INDEX); diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java index 4f40cc0ff50..b700d78d34e 100644 --- a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java @@ -40,10 +40,12 @@ *

    *
  1. {@code init()}: captures the current {@link Context} and an {@link Continuation} to prevent * the enclosing context scope from completing early. - *
  2. {@code mount()}: swaps the virtual thread's saved context into the carrier thread, saving - * the carrier thread's context. - *
  3. {@code unmount()}: swaps the carrier thread's original context back, saving the virtual - * thread's (possibly modified) context for the next mount. + *
  4. {@code mount()}: on the first mount, seeds the virtual thread's scope stack with the + * captured context; on later mounts, re-applies the profiler context to the current carrier + * (no-op unless carrier-bound profiling is active). + *
  5. {@code unmount()}: clears the carrier's profiler context (no-op unless carrier-bound + * profiling is active). The scope stack is NOT swapped out — it lives in the virtual thread's + * own thread-local and follows it across park/unpark and carrier migration. *
  6. Steps 2-3 repeat on each park/unpark cycle, potentially on different carrier threads. *
  7. {@code afterDone()} / {@code afterTerminate()} for early VirtualThread support: cancels the * help continuation, releasing the context scope to be closed. diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java index 54690e14f73..22e2be954ec 100644 --- a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java @@ -212,6 +212,59 @@ public void run() { span().childOfPrevious().operationName("child"))); } + @DisplayName("test context preserved across carrier migration") + @Test + void testContextPreservedAcrossCarrierMigration() { + // Constrain the carrier pool so parked VTs resume on different carriers. + String previousParallelism = System.getProperty("jdk.virtualThreadScheduler.parallelism"); + System.setProperty("jdk.virtualThreadScheduler.parallelism", "2"); + try { + int threadCount = 32; + String[] parentSpanId = new String[1]; + String[] childParentSpanIds = new String[threadCount]; + + new Runnable() { + @Override + @Trace(operationName = "parent") + public void run() { + parentSpanId[0] = GlobalTracer.get().getSpanId(); + List threads = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + int index = i; + threads.add( + Thread.startVirtualThread( + () -> { + // Multiple park/unpark cycles to provoke carrier migration. + tryUnmount(); + childParentSpanIds[index] = GlobalTracer.get().getSpanId(); + })); + } + for (Thread thread : threads) { + try { + thread.join(TIMEOUT); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + }.run(); + + for (int i = 0; i < threadCount; i++) { + assertEquals( + parentSpanId[0], + childParentSpanIds[i], + "context must survive park/unpark and carrier migration for VT #" + i); + } + assertTraces(trace(span().root().operationName("parent"))); + } finally { + if (previousParallelism == null) { + System.clearProperty("jdk.virtualThreadScheduler.parallelism"); + } else { + System.setProperty("jdk.virtualThreadScheduler.parallelism", previousParallelism); + } + } + } + @Trace(operationName = "child") private static void childWork(String[] beforeUnmount, String[] afterRemount) { beforeUnmount[0] = GlobalTracer.get().getSpanId(); diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/VirtualThreadContextBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/VirtualThreadContextBenchmark.java new file mode 100644 index 00000000000..ea4a7722c43 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/VirtualThreadContextBenchmark.java @@ -0,0 +1,185 @@ +package datadog.trace.core; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.context.Context; +import datadog.trace.api.EndpointTracker; +import datadog.trace.api.Stateful; +import datadog.trace.api.profiling.ProfilingContextAttribute; +import datadog.trace.api.profiling.ProfilingScope; +import datadog.trace.api.profiling.Timer.TimerType; +import datadog.trace.api.profiling.Timing; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.ProfilerContext; +import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; +import datadog.trace.core.monitor.HealthMetrics; +import datadog.trace.core.scopemanager.ContinuableScopeManager; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Per park/unpark cost of virtual-thread context propagation: the current full {@link + * ContinuableScopeManager#swap(Context)} on every mount/unmount versus the proposed seed-once path + * (nothing when profiling is off, a profiler rebind/unbind when it is on). ddprof's native {@code + * setContext} is modelled by a stub {@link ProfilingContextIntegration} whose {@link Stateful} + * writes four volatile longs and allocates nothing. + * + *
    + * {@code ./gradlew :dd-trace-core:jmh -Pjmh.includes=VirtualThreadContextBenchmark -PtestJvm=21 -Pjmh.profilers=gc}
    + * 
    + * + *

    Sample run (JDK 21.0.9, {@code @Threads(8)}; run-to-run variance is high, the alloc/op figures + * are stable): + * + *

    {@code
    + * Benchmark                                                   Mode  Cnt     Score     Error   Units
    + * currentCycle_profilingOff                                  thrpt    5   463.841 ± 251.726  ops/us
    + * currentCycle_profilingOff:gc.alloc.rate.norm               thrpt    5   176.002 ±   0.016    B/op
    + * currentCycle_profilingOn                                   thrpt    5   272.253 ±  21.041  ops/us
    + * currentCycle_profilingOn:gc.alloc.rate.norm                thrpt    5   288.003 ±   0.022    B/op
    + * proposedSteady_profilingOff                                thrpt    5  5010.376 ± 354.716  ops/us
    + * proposedSteady_profilingOff:gc.alloc.rate.norm             thrpt    5    ~0                   B/op
    + * proposedRebindUnbind_profilingOn                           thrpt    5  1755.815 ± 473.954  ops/us
    + * proposedRebindUnbind_profilingOn:gc.alloc.rate.norm        thrpt    5    ~0                   B/op
    + * }
    + */ +@State(Scope.Benchmark) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 1) +public class VirtualThreadContextBenchmark { + + static final CoreTracer TRACER = CoreTracer.builder().build(); + + ContinuableScopeManager plainManager; // profiling off + ContinuableScopeManager profiledManager; // profiling on + + @Setup + public void setup() { + plainManager = new ContinuableScopeManager(0, false); + profiledManager = + new ContinuableScopeManager(0, false, new StubProfiling(), HealthMetrics.NO_OP); + } + + @State(Scope.Thread) + public static class ThreadState { + AgentSpan span; + Context spanContext; + Stateful profilerState; + + @Setup(Level.Trial) + public void setup(VirtualThreadContextBenchmark bench) { + span = TRACER.startSpan("benchmark", "vt"); + spanContext = span; + profilerState = new StubProfiling().newScopeState((ProfilerContext) span.spanContext()); + } + + @TearDown(Level.Trial) + public void tearDown() { + span.finish(); + } + } + + @Benchmark + public void currentCycle_profilingOff(ThreadState t) { + Context previous = plainManager.swap(t.spanContext); + plainManager.swap(previous); + } + + @Benchmark + public void currentCycle_profilingOn(ThreadState t) { + Context previous = profiledManager.swap(t.spanContext); + profiledManager.swap(previous); + } + + // current() is the faithful upper bound for the seed-once steady state (a scope-stack read). + @Benchmark + public Context proposedSteady_profilingOff(ThreadState t) { + return plainManager.current(); + } + + @Benchmark + public Context proposedRebindUnbind_profilingOn(ThreadState t) { + Context active = profiledManager.current(); + t.profilerState.activate(t.span.spanContext()); + t.profilerState.close(); + return active; + } + + static final class StubProfiling implements ProfilingContextIntegration { + @Override + public Stateful newScopeState(ProfilerContext profilerContext) { + // Per-scope storage mirrors ddprof's per-OS-thread native slots, avoiding the cross-thread + // cache contention a single shared instance would introduce. + return new StubState(); + } + + @Override + public String name() { + return "stub"; + } + + @Override + public ProfilingContextAttribute createContextAttribute(String attribute) { + return ProfilingContextAttribute.NoOp.INSTANCE; + } + + @Override + public ProfilingScope newScope() { + return ProfilingScope.NO_OP; + } + + @Override + public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} + + @Override + public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { + return EndpointTracker.NO_OP; + } + + @Override + public Timing start(TimerType type) { + return Timing.NoOp.INSTANCE; + } + } + + static final class StubState implements Stateful { + volatile long rootSpanId; + volatile long spanId; + volatile long traceHigh; + volatile long traceLow; + + @Override + public void activate(Object context) { + if (context instanceof ProfilerContext) { + ProfilerContext c = (ProfilerContext) context; + rootSpanId = c.getRootSpanId(); + spanId = c.getSpanId(); + traceHigh = c.getTraceIdHigh(); + traceLow = c.getTraceIdLow(); + } + } + + @Override + public void close() { + rootSpanId = 0; + spanId = 0; + traceHigh = 0; + traceLow = 0; + } + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index 75513b1b01e..7674ee4c075 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -1438,6 +1438,16 @@ public void addScopeListener(final ScopeListener listener) { this.scopeManager.addScopeListener(listener); } + @Override + public void rebindProfilingContextToCarrier() { + scopeManager.rebindProfilingContextToCarrier(); + } + + @Override + public void unbindProfilingContextFromCarrier() { + scopeManager.unbindProfilingContextFromCarrier(); + } + @Override public SubscriptionService getSubscriptionService(RequestContextSlot slot) { return (SubscriptionService) instrumentationGateway.getCallbackProvider(slot); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java b/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java index ba7e5288a68..9510e8629d8 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java @@ -177,6 +177,23 @@ public final void beforeActivated() { } } + /** + * Clears the profiler context for this scope's state without closing the scope itself. The scope + * state must be idempotent and reusable, as this may be followed by another {@link + * #beforeActivated()} on the same scope (e.g. across virtual-thread unmount/remount). + */ + public final void clearProfilingContext() { + if (scopeState == Stateful.DEFAULT) { + return; + } + try { + scopeState.close(); + } catch (Throwable e) { + ContinuableScopeManager.ratelimitedLog.warn( + "ScopeState {} threw exception in clearProfilingContext()", scopeState.getClass(), e); + } + } + public final void afterActivated() { boolean hasScopeListeners = !scopeManager.scopeListeners.isEmpty(); boolean hasExtendedListeners = !scopeManager.extendedScopeListeners.isEmpty(); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java b/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java index 04fbd637bb3..b07ead3a400 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java @@ -62,6 +62,7 @@ public final class ContinuableScopeManager implements ContextManager { final HealthMetrics healthMetrics; private final ProfilingContextIntegration profilingContextIntegration; private final boolean profilingEnabled; + private final boolean profilerCarrierBound; private final boolean hasDepthLimit; /** @@ -95,6 +96,8 @@ public ContinuableScopeManager( this.profilingContextIntegration = profilingContextIntegration; this.profilingEnabled = !(profilingContextIntegration instanceof ProfilingContextIntegration.NoOp); + this.profilerCarrierBound = + this.profilingEnabled && profilingContextIntegration.isCarrierThreadBound(); ContextManager.register(this); } @@ -370,6 +373,34 @@ ScopeStack scopeStack() { return this.tlsScopeStack.get(); } + /** + * Re-applies the active scope's profiler context to the current carrier; no-op unless a + * carrier-bound profiling integration is active. + */ + public void rebindProfilingContextToCarrier() { + if (!profilerCarrierBound) { + return; + } + final ContinuableScope active = scopeStack().active(); + if (active != null) { + active.beforeActivated(); + } + } + + /** + * Clears the current carrier's profiler context; no-op unless a carrier-bound profiling + * integration is active. + */ + public void unbindProfilingContextFromCarrier() { + if (!profilerCarrierBound) { + return; + } + final ContinuableScope active = scopeStack().active(); + if (active != null) { + active.clearProfilingContext(); + } + } + @Override public Context current() { final ContinuableScope active = scopeStack().active(); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java b/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java new file mode 100644 index 00000000000..9a815ab4839 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java @@ -0,0 +1,44 @@ +package datadog.trace.core; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import org.junit.jupiter.api.Test; + +class CoreTracerCarrierRebindTest { + + @Test + void tracerRebindUnbindAreSafeNoOpsWithoutProfiling() { + CoreTracer tracer = CoreTracer.builder().build(); + AgentSpan span = tracer.startSpan("test", "op"); + try (AgentScopeCloser ignored = new AgentScopeCloser(tracer.activateSpan(span))) { + // Profiling is disabled by default, so both calls must be harmless no-ops. + assertDoesNotThrow(tracer::rebindProfilingContextToCarrier); + assertDoesNotThrow(tracer::unbindProfilingContextFromCarrier); + } finally { + span.finish(); + } + } + + @Test + void noopTracerRebindUnbindDoNotThrow() { + AgentTracer.TracerAPI noop = AgentTracer.NOOP_TRACER; + assertDoesNotThrow(noop::rebindProfilingContextToCarrier); + assertDoesNotThrow(noop::unbindProfilingContextFromCarrier); + } + + /** Minimal try-with-resources helper around AgentScope. */ + static final class AgentScopeCloser implements AutoCloseable { + private final datadog.trace.bootstrap.instrumentation.api.AgentScope scope; + + AgentScopeCloser(datadog.trace.bootstrap.instrumentation.api.AgentScope scope) { + this.scope = scope; + } + + @Override + public void close() { + scope.close(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java b/dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java new file mode 100644 index 00000000000..fd1f705f0f4 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java @@ -0,0 +1,123 @@ +package datadog.trace.core.scopemanager; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.context.ContextScope; +import datadog.trace.api.EndpointTracker; +import datadog.trace.api.Stateful; +import datadog.trace.api.profiling.ProfilingContextAttribute; +import datadog.trace.api.profiling.ProfilingScope; +import datadog.trace.api.profiling.Timer.TimerType; +import datadog.trace.api.profiling.Timing; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.ProfilerContext; +import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; +import datadog.trace.core.CoreTracer; +import datadog.trace.core.monitor.HealthMetrics; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class CarrierProfilerRebindTest { + + static final CoreTracer TRACER = CoreTracer.builder().build(); + + /** Records activate/close counts so the test can observe carrier rebind/unbind. */ + static final class CountingProfiling implements ProfilingContextIntegration { + final AtomicInteger activations = new AtomicInteger(); + final AtomicInteger closes = new AtomicInteger(); + private final boolean carrierBound; + + CountingProfiling(boolean carrierBound) { + this.carrierBound = carrierBound; + } + + @Override + public boolean isCarrierThreadBound() { + return carrierBound; + } + + @Override + public Stateful newScopeState(ProfilerContext profilerContext) { + return new Stateful() { + @Override + public void activate(Object context) { + activations.incrementAndGet(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + }; + } + + @Override + public String name() { + return "counting"; + } + + @Override + public ProfilingContextAttribute createContextAttribute(String attribute) { + return ProfilingContextAttribute.NoOp.INSTANCE; + } + + @Override + public ProfilingScope newScope() { + return ProfilingScope.NO_OP; + } + + @Override + public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} + + @Override + public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { + return EndpointTracker.NO_OP; + } + + @Override + public Timing start(TimerType type) { + return Timing.NoOp.INSTANCE; + } + } + + @Test + void rebindAndUnbindDriveProfilerWhenCarrierBound() { + CountingProfiling profiling = new CountingProfiling(true); + ContinuableScopeManager manager = + new ContinuableScopeManager(0, false, profiling, HealthMetrics.NO_OP); + AgentSpan span = TRACER.startSpan("test", "op"); + try (ContextScope scope = manager.attach(span)) { + // attach already activated the profiler once; measure only rebind/unbind from here. + profiling.activations.set(0); + profiling.closes.set(0); + + manager.rebindProfilingContextToCarrier(); + manager.unbindProfilingContextFromCarrier(); + + assertEquals(1, profiling.activations.get(), "rebind should re-apply profiler context"); + assertEquals(1, profiling.closes.get(), "unbind should clear profiler context"); + } finally { + span.finish(); + } + } + + @Test + void rebindAndUnbindAreNoOpWhenNotCarrierBound() { + CountingProfiling profiling = new CountingProfiling(false); + ContinuableScopeManager manager = + new ContinuableScopeManager(0, false, profiling, HealthMetrics.NO_OP); + AgentSpan span = TRACER.startSpan("test", "op"); + try (ContextScope scope = manager.attach(span)) { + profiling.activations.set(0); + profiling.closes.set(0); + + manager.rebindProfilingContextToCarrier(); + manager.unbindProfilingContextFromCarrier(); + + assertEquals(0, profiling.activations.get(), "no rebind when not carrier bound"); + assertEquals(0, profiling.closes.get(), "no unbind when not carrier bound"); + } finally { + span.finish(); + } + } +} diff --git a/docs/superpowers/plans/2026-07-08-virtual-thread-instrumentation-performance.md b/docs/superpowers/plans/2026-07-08-virtual-thread-instrumentation-performance.md new file mode 100644 index 00000000000..dea2adb5362 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-virtual-thread-instrumentation-performance.md @@ -0,0 +1,754 @@ +# Virtual Thread Instrumentation Performance Redesign — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop swapping the whole scope stack on every virtual-thread park/unpark; seed the context once and, only when carrier-bound (ddprof) profiling is active, do an allocation-free profiler rebind/unbind per mount/unmount. + +**Architecture:** The trace scope stack lives in a native `ThreadLocal` that is virtual-thread-aware (follows the VT across park/unpark and carrier migration), so it needs seeding exactly once on first mount. The ddprof profiler context lives in a carrier-OS-thread-keyed native slot that is *not* VT-aware, so it needs a cheap re-apply on mount and clear on unmount — but only for integrations that report themselves carrier-bound. A new capability flag on `ProfilingContextIntegration` plus two new `TracerAPI`/`ContinuableScopeManager` methods keep ddprof knowledge in dd-trace-core; `VirtualThreadState` calls them. + +**Tech Stack:** Java, ByteBuddy advice (java-lang-21.0 instrumentation), JUnit 5, dd-trace-core scope manager. + +## Global Constraints + +- Instrumentation advice/helper code must compile to Java 8 bytecode; `VirtualThreadState` and `VirtualThreadInstrumentation` must not use APIs newer than the module allows. (Existing constraint — do not introduce new JDK 21 API usage in these files.) +- `VirtualThreadState` is a bootstrap helper (`agent-bootstrap`), reached from advice; it may use `datadog.context.Context`, `AgentTracer`, and `AgentScope.Continuation` (all bootstrap-visible). It must NOT reference ddprof/profiling implementation classes directly. +- New unit tests: JUnit 5 + Java. Instrumentation tests stay in the existing JUnit 5 Java suite under `testdog.trace.instrumentation.java.lang.jdk21`. +- Run java-lang-21.0 tests on JDK 21: `-PtestJvm=21`. +- Format with `./gradlew spotlessApply` before each commit. +- Do NOT auto-commit unless a step says to; the repo owner reviews diffs. (This plan includes commit steps; the executor should still surface diffs.) + +## File Structure + +- `internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java` — add `default boolean isCarrierThreadBound()`. +- `dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java` — override `isCarrierThreadBound()` → `true`. +- `dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java` — add `deactivateProfiling()`. +- `dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java` — add `profilerCarrierBound` field + `rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()`. +- `internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java` — add two `default` no-op methods to `TracerAPI`. +- `dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java` — override the two methods, delegating to `scopeManager`. +- `dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java` — rewrite to seed-once + rebind/unbind. +- `dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/.../VirtualThreadInstrumentation.java` — javadoc/comment update only (advice bodies unchanged). +- Tests: `dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java` (new), and additions to `.../java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java`. + +--- + +### Task 1: Carrier-bound capability flag on `ProfilingContextIntegration` + +**Files:** +- Modify: `internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java` +- Modify: `dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java` +- Test: `internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java` (new) + +**Interfaces:** +- Produces: `boolean ProfilingContextIntegration.isCarrierThreadBound()` — default `false`; `true` for ddprof. Used by Task 2 to gate the profiler rebind/unbind. + +- [ ] **Step 1: Write the failing test** + +Create `internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java`: + +```java +package datadog.trace.bootstrap.instrumentation.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.Test; + +class ProfilingContextIntegrationTest { + @Test + void defaultIntegrationIsNotCarrierThreadBound() { + ProfilingContextIntegration integration = () -> "test-only"; + assertFalse(integration.isCarrierThreadBound()); + } + + @Test + void noOpIntegrationIsNotCarrierThreadBound() { + assertFalse(ProfilingContextIntegration.NoOp.INSTANCE.isCarrierThreadBound()); + } +} +``` + +Note: `ProfilingContextIntegration` has a single abstract method `name()`, so `() -> "test-only"` is a valid lambda instance. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :internal-api:test --tests 'datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegrationTest'` +Expected: FAIL to COMPILE — `cannot find symbol: method isCarrierThreadBound()`. + +- [ ] **Step 3: Add the default method** + +In `ProfilingContextIntegration.java`, add immediately after the `onDetach()` default method (around line 19): + +```java + /** + * Whether this integration stores the active span context in a carrier / OS-thread-keyed slot + * (e.g. ddprof's native {@code setContext}) rather than a virtual-thread-aware Java {@code + * ThreadLocal}. When {@code true}, the context must be re-applied to the current carrier on every + * virtual-thread mount and cleared on unmount. + */ + default boolean isCarrierThreadBound() { + return false; + } +``` + +- [ ] **Step 4: Override in the ddprof integration** + +In `DatadogProfilingIntegration.java`, add this method (next to `name()`, around lines 76-79): + +```java + @Override + public boolean isCarrierThreadBound() { + return true; + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `./gradlew :internal-api:test --tests 'datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegrationTest'` +Expected: PASS (2 tests). + +- [ ] **Step 6: Format and commit** + +```bash +./gradlew spotlessApply +git add internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java \ + internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java \ + dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java +git commit -m "Add ProfilingContextIntegration.isCarrierThreadBound capability flag" +``` + +--- + +### Task 2: Profiler rebind/unbind on the scope manager + +**Files:** +- Modify: `dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java` +- Modify: `dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java` +- Test: `dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java` (new) + +**Interfaces:** +- Consumes: `ProfilingContextIntegration.isCarrierThreadBound()` (Task 1); existing package-private `ContinuableScopeManager.scopeStack()`, `ScopeStack.active()`, public `ContinuableScope.beforeActivated()`. +- Produces: + - `void ContinuableScope.deactivateProfiling()` — clears the scope's profiler state without closing the scope. + - `void ContinuableScopeManager.rebindProfilingContextToCarrier()` — re-applies the active scope's profiler context to the current carrier; no-op unless carrier-bound profiling is on. + - `void ContinuableScopeManager.unbindProfilingContextFromCarrier()` — clears the current carrier's profiler slot; same gating. + +- [ ] **Step 1: Write the failing test** + +Create `dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java`: + +```java +package datadog.trace.core.scopemanager; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.context.ContextScope; +import datadog.trace.api.Stateful; +import datadog.trace.api.profiling.ProfilingContextAttribute; +import datadog.trace.api.profiling.ProfilingScope; +import datadog.trace.api.profiling.Timer.TimerType; +import datadog.trace.api.profiling.Timing; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.EndpointTracker; +import datadog.trace.bootstrap.instrumentation.api.ProfilerContext; +import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; +import datadog.trace.core.CoreTracer; +import datadog.trace.core.monitor.HealthMetrics; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class CarrierProfilerRebindTest { + + static final CoreTracer TRACER = CoreTracer.builder().build(); + + /** Records activate/close counts so the test can observe carrier rebind/unbind. */ + static final class CountingProfiling implements ProfilingContextIntegration { + final AtomicInteger activations = new AtomicInteger(); + final AtomicInteger closes = new AtomicInteger(); + private final boolean carrierBound; + + CountingProfiling(boolean carrierBound) { + this.carrierBound = carrierBound; + } + + @Override + public boolean isCarrierThreadBound() { + return carrierBound; + } + + @Override + public Stateful newScopeState(ProfilerContext profilerContext) { + return new Stateful() { + @Override + public void activate(Object context) { + activations.incrementAndGet(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + }; + } + + @Override + public String name() { + return "counting"; + } + + @Override + public ProfilingContextAttribute createContextAttribute(String attribute) { + return ProfilingContextAttribute.NoOp.INSTANCE; + } + + @Override + public ProfilingScope newScope() { + return ProfilingScope.NO_OP; + } + + @Override + public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} + + @Override + public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { + return EndpointTracker.NO_OP; + } + + @Override + public Timing start(TimerType type) { + return Timing.NoOp.INSTANCE; + } + } + + @Test + void rebindAndUnbindDriveProfilerWhenCarrierBound() { + CountingProfiling profiling = new CountingProfiling(true); + ContinuableScopeManager manager = + new ContinuableScopeManager(0, false, profiling, HealthMetrics.NO_OP); + AgentSpan span = TRACER.startSpan("test", "op"); + try (ContextScope scope = manager.attach(span)) { + // attach already activated the profiler once; measure only rebind/unbind from here. + profiling.activations.set(0); + profiling.closes.set(0); + + manager.rebindProfilingContextToCarrier(); + manager.unbindProfilingContextFromCarrier(); + + assertEquals(1, profiling.activations.get(), "rebind should re-apply profiler context"); + assertEquals(1, profiling.closes.get(), "unbind should clear profiler context"); + } finally { + span.finish(); + } + } + + @Test + void rebindAndUnbindAreNoOpWhenNotCarrierBound() { + CountingProfiling profiling = new CountingProfiling(false); + ContinuableScopeManager manager = + new ContinuableScopeManager(0, false, profiling, HealthMetrics.NO_OP); + AgentSpan span = TRACER.startSpan("test", "op"); + try (ContextScope scope = manager.attach(span)) { + profiling.activations.set(0); + profiling.closes.set(0); + + manager.rebindProfilingContextToCarrier(); + manager.unbindProfilingContextFromCarrier(); + + assertEquals(0, profiling.activations.get(), "no rebind when not carrier bound"); + assertEquals(0, profiling.closes.get(), "no unbind when not carrier bound"); + } finally { + span.finish(); + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :dd-trace-core:test --tests 'datadog.trace.core.scopemanager.CarrierProfilerRebindTest'` +Expected: FAIL to COMPILE — `cannot find symbol: method rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()`. + +- [ ] **Step 3: Add `deactivateProfiling()` to `ContinuableScope`** + +In `ContinuableScope.java`, add after `beforeActivated()` (after line 178): + +```java + /** Clears the profiler context for this scope's state without closing the scope itself. */ + public final void deactivateProfiling() { + if (scopeState == Stateful.DEFAULT) { + return; + } + try { + scopeState.close(); + } catch (Throwable e) { + ContinuableScopeManager.ratelimitedLog.warn( + "ScopeState {} threw exception in deactivateProfiling()", scopeState.getClass(), e); + } + } +``` + +- [ ] **Step 4: Add the field and methods to `ContinuableScopeManager`** + +In `ContinuableScopeManager.java`, add a field next to `profilingEnabled` (after line 64): + +```java + private final boolean profilerCarrierBound; +``` + +In the 4-arg constructor, immediately after the `this.profilingEnabled = ...` assignment (after line 97), add: + +```java + this.profilerCarrierBound = + this.profilingEnabled && profilingContextIntegration.isCarrierThreadBound(); +``` + +Add these two public methods near `scopeStack()` (after line 371): + +```java + /** + * Re-applies the active scope's profiler context to the current carrier thread. No-op unless a + * carrier-thread-bound profiling integration (e.g. ddprof) is active. Called on virtual-thread + * mount, where the carrier's native context slot must be refreshed after (possibly) migrating + * carriers. + */ + public void rebindProfilingContextToCarrier() { + if (!profilerCarrierBound) { + return; + } + final ContinuableScope active = scopeStack().active(); + if (active != null) { + active.beforeActivated(); + } + } + + /** + * Clears the current carrier thread's profiler context slot. No-op unless a carrier-thread-bound + * profiling integration is active. Called on virtual-thread unmount so the carrier is not left + * attributed to the virtual thread's span. + */ + public void unbindProfilingContextFromCarrier() { + if (!profilerCarrierBound) { + return; + } + final ContinuableScope active = scopeStack().active(); + if (active != null) { + active.deactivateProfiling(); + } + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `./gradlew :dd-trace-core:test --tests 'datadog.trace.core.scopemanager.CarrierProfilerRebindTest'` +Expected: PASS (2 tests). + +- [ ] **Step 6: Format and commit** + +```bash +./gradlew spotlessApply +git add dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java \ + dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java \ + dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java +git commit -m "Add carrier profiler rebind/unbind to ContinuableScopeManager" +``` + +--- + +### Task 3: Expose rebind/unbind on `TracerAPI` and `CoreTracer` + +**Files:** +- Modify: `internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java` +- Modify: `dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java` +- Test: `dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java` (new) + +**Interfaces:** +- Consumes: `ContinuableScopeManager.rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()` (Task 2). +- Produces: `void TracerAPI.rebindProfilingContextToCarrier()` and `void TracerAPI.unbindProfilingContextFromCarrier()` — `default` no-ops on the interface, delegating overrides on `CoreTracer`. Used by Task 4 via `AgentTracer.get()`. + +- [ ] **Step 1: Write the failing test** + +Create `dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java`: + +```java +package datadog.trace.core; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import org.junit.jupiter.api.Test; + +class CoreTracerCarrierRebindTest { + + @Test + void tracerRebindUnbindAreSafeNoOpsWithoutProfiling() { + CoreTracer tracer = CoreTracer.builder().build(); + AgentSpan span = tracer.startSpan("test", "op"); + try (AgentScopeCloser ignored = new AgentScopeCloser(tracer.activateSpan(span))) { + // Profiling is disabled by default, so both calls must be harmless no-ops. + assertDoesNotThrow(tracer::rebindProfilingContextToCarrier); + assertDoesNotThrow(tracer::unbindProfilingContextFromCarrier); + } finally { + span.finish(); + } + } + + @Test + void noopTracerRebindUnbindDoNotThrow() { + AgentTracer.TracerAPI noop = AgentTracer.NoopTracerAPI.INSTANCE; + assertDoesNotThrow(noop::rebindProfilingContextToCarrier); + assertDoesNotThrow(noop::unbindProfilingContextFromCarrier); + } + + /** Minimal try-with-resources helper around AgentScope. */ + static final class AgentScopeCloser implements AutoCloseable { + private final datadog.trace.bootstrap.instrumentation.api.AgentScope scope; + + AgentScopeCloser(datadog.trace.bootstrap.instrumentation.api.AgentScope scope) { + this.scope = scope; + } + + @Override + public void close() { + scope.close(); + } + } +} +``` + +Note: verify `AgentTracer.NoopTracerAPI.INSTANCE` is accessible from this package; the exploration found `NoopTracerAPI` is a static nested class in `AgentTracer`. If `INSTANCE` is not visible, replace the second test body with `assertDoesNotThrow(() -> ((AgentTracer.TracerAPI) AgentTracer.get()).rebindProfilingContextToCarrier())` after confirming the default provider — but prefer the explicit NoopTracerAPI reference if visible. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :dd-trace-core:test --tests 'datadog.trace.core.CoreTracerCarrierRebindTest'` +Expected: FAIL to COMPILE — `cannot find symbol: method rebindProfilingContextToCarrier()`. + +- [ ] **Step 3: Add default no-op methods to `TracerAPI`** + +In `AgentTracer.java`, inside the `TracerAPI` interface, add after `getProfilingContext()` (after line 391): + +```java + /** + * Re-applies the active scope's profiler context to the current carrier thread. No-op unless a + * carrier-thread-bound profiling integration is active. Used by virtual-thread instrumentation + * on mount. + */ + default void rebindProfilingContextToCarrier() {} + + /** + * Clears the current carrier thread's profiler context slot. No-op unless a carrier-thread-bound + * profiling integration is active. Used by virtual-thread instrumentation on unmount. + */ + default void unbindProfilingContextFromCarrier() {} +``` + +Because these are `default` methods, `NoopTracerAPI` needs no change. + +- [ ] **Step 4: Override in `CoreTracer` to delegate** + +In `CoreTracer.java`, add near the other scope delegations (the class has a `private final ContinuableScopeManager scopeManager;` field). Place after an existing scope-delegating method such as `addScopeListener`: + +```java + @Override + public void rebindProfilingContextToCarrier() { + scopeManager.rebindProfilingContextToCarrier(); + } + + @Override + public void unbindProfilingContextFromCarrier() { + scopeManager.unbindProfilingContextFromCarrier(); + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `./gradlew :dd-trace-core:test --tests 'datadog.trace.core.CoreTracerCarrierRebindTest'` +Expected: PASS (2 tests). + +- [ ] **Step 6: Format and commit** + +```bash +./gradlew spotlessApply +git add internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java \ + dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java \ + dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java +git commit -m "Expose carrier profiler rebind/unbind on TracerAPI and CoreTracer" +``` + +--- + +### Task 4: Rewrite `VirtualThreadState` to seed-once + rebind/unbind + +**Files:** +- Modify: `dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java` +- Modify: `dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java` (javadoc only) +- Test: existing `.../java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java` and `VirtualThreadApiInstrumentationTest.java` (regression guard — no new file here; new migration test added in Task 5) + +**Interfaces:** +- Consumes: `AgentTracer.get().rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()` (Task 3); `Context.swap()`; existing constructor signature `VirtualThreadState(Context, AgentScope.Continuation)` — unchanged so `VirtualThreadInstrumentation$Construct` needs no change. +- Produces: `onMount()`, `onUnmount()`, `onTerminate()` (same names/signatures the advice already calls). + +- [ ] **Step 1: Confirm the regression baseline is green (pre-change)** + +Run: `./gradlew :dd-java-agent:instrumentation:java:java-lang:java-lang-21.0:test -PtestJvm=21` +Expected: PASS. This is the behavioral contract the rewrite must preserve. (If it fails before any change, stop and investigate the environment.) + +- [ ] **Step 2: Rewrite `VirtualThreadState.java`** + +Replace the entire file with: + +```java +package datadog.trace.bootstrap.instrumentation.java.lang; + +import datadog.context.Context; +import datadog.trace.bootstrap.instrumentation.api.AgentScope.Continuation; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; + +/** + * Holds the seed context and scope continuation for a virtual thread. + * + *

    Used by java-lang-21.0 {@code VirtualThreadInstrumentation}. The virtual thread's scope stack + * lives in a virtual-thread-aware {@code ThreadLocal}, so it only needs seeding once on the first + * mount; it then follows the virtual thread across park/unpark and carrier migration on its own. On + * subsequent mounts/unmounts the only per-cycle work is a lightweight profiler rebind/unbind, and + * only when a carrier-thread-bound profiling integration (ddprof) is active — otherwise those calls + * are no-ops. + */ +public final class VirtualThreadState { + /** The parent context captured at construction; installed once on first mount, then released. */ + private Context seedContext; + + /** Prevents the enclosing context scope from completing before the virtual thread finishes. */ + private final Continuation continuation; + + /** Whether the seed context has been installed into the virtual thread's scope stack. */ + private boolean seeded; + + public VirtualThreadState(Context seedContext, Continuation continuation) { + this.seedContext = seedContext; + this.continuation = continuation; + } + + /** + * Called on mount (running as the virtual thread). On the first mount, installs the seed context + * into the virtual thread's scope stack (this also applies the profiler context to the first + * carrier). On later mounts, re-applies the active scope's profiler context to the current + * carrier. + */ + public void onMount() { + if (!seeded) { + seedContext.swap(); + seeded = true; + seedContext = null; + } else { + AgentTracer.get().rebindProfilingContextToCarrier(); + } + } + + /** Called on unmount (running as the virtual thread): clears the carrier's profiler context. */ + public void onUnmount() { + AgentTracer.get().unbindProfilingContextFromCarrier(); + } + + /** Called on termination: releases the trace continuation. */ + public void onTerminate() { + if (this.continuation != null) { + this.continuation.cancel(); + } + } +} +``` + +- [ ] **Step 3: Update the instrumentation javadoc (no behavioral change)** + +In `VirtualThreadInstrumentation.java`, replace the class-level lifecycle javadoc (lines 33-60) description of mount/unmount so it reflects seed-once semantics. Change the `

  8. {@code mount()}` and `
  9. {@code unmount()}` bullets to: + +```java + *
  10. {@code mount()}: on the first mount, seeds the virtual thread's scope stack with the + * captured context; on later mounts, re-applies the profiler context to the current carrier + * (no-op unless carrier-bound profiling is active). + *
  11. {@code unmount()}: clears the carrier's profiler context (no-op unless carrier-bound + * profiling is active). The scope stack is NOT swapped out — it lives in the virtual thread's + * own thread-local and follows it across park/unpark and carrier migration. +``` + +Leave the advice classes (`Construct`, `Mount`, `Unmount`, `AfterDone`), `contextStore()`, `excludedClasses()`, and `preloadClassNames()` unchanged. + +- [ ] **Step 4: Run the regression suite to verify behavior is preserved** + +Run: `./gradlew :dd-java-agent:instrumentation:java:java-lang:java-lang-21.0:test -PtestJvm=21` +Expected: PASS — all existing `VirtualThreadLifeCycleTest` and `VirtualThreadApiInstrumentationTest` cases green (parent inheritance, context restored after remount, child-span ordering across unmount/remount, concurrent VTs, no-context VT). + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew spotlessApply +git add dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java \ + dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java +git commit -m "Seed virtual-thread context once instead of swapping per mount/unmount" +``` + +--- + +### Task 5: Add a carrier-migration regression test + +**Files:** +- Modify: `dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java` + +**Interfaces:** +- Consumes: the rewritten `VirtualThreadState` behavior (Task 4). Uses the existing `AbstractInstrumentationTest`, `@Trace`, `GlobalTracer`, `span()`/`trace()` assertion DSL already imported in the file. + +- [ ] **Step 1: Write the failing-then-passing test** + +Add this method to `VirtualThreadLifeCycleTest` (before the private helpers at line 215). It forces a small carrier pool so virtual threads are highly likely to resume on different carriers, and asserts context is preserved and a child span is parented correctly across migration: + +```java + @DisplayName("test context preserved across carrier migration") + @Test + void testContextPreservedAcrossCarrierMigration() { + // Constrain the carrier pool so parked VTs resume on different carriers. + String previousParallelism = + System.getProperty("jdk.virtualThreadScheduler.parallelism"); + System.setProperty("jdk.virtualThreadScheduler.parallelism", "2"); + try { + int threadCount = 32; + String[] parentSpanId = new String[1]; + String[] childParentSpanIds = new String[threadCount]; + + new Runnable() { + @Override + @Trace(operationName = "parent") + public void run() { + parentSpanId[0] = GlobalTracer.get().getSpanId(); + List threads = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + int index = i; + threads.add( + Thread.startVirtualThread( + () -> { + // Multiple park/unpark cycles to provoke carrier migration. + tryUnmount(); + childParentSpanIds[index] = GlobalTracer.get().getSpanId(); + })); + } + for (Thread thread : threads) { + try { + thread.join(TIMEOUT); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + }.run(); + + for (int i = 0; i < threadCount; i++) { + assertEquals( + parentSpanId[0], + childParentSpanIds[i], + "context must survive park/unpark and carrier migration for VT #" + i); + } + assertTraces(trace(span().root().operationName("parent"))); + } finally { + if (previousParallelism == null) { + System.clearProperty("jdk.virtualThreadScheduler.parallelism"); + } else { + System.setProperty("jdk.virtualThreadScheduler.parallelism", previousParallelism); + } + } + } +``` + +Note: `jdk.virtualThreadScheduler.parallelism` is read when the default scheduler initializes. If the suite has already started virtual threads, this property may have no effect on the running scheduler; the test still validates propagation across many park/unpark cycles regardless. Keep the property set for best-effort migration. + +- [ ] **Step 2: Run the test** + +Run: `./gradlew :dd-java-agent:instrumentation:java:java-lang:java-lang-21.0:test -PtestJvm=21 --tests '*VirtualThreadLifeCycleTest'` +Expected: PASS, including the new `testContextPreservedAcrossCarrierMigration`. + +- [ ] **Step 3: Format and commit** + +```bash +./gradlew spotlessApply +git add dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java +git commit -m "Add carrier-migration regression test for virtual-thread context" +``` + +--- + +### Task 6: Verify profiler attribution end-to-end (manual/smoke) + +**Files:** +- No source change. Verification only. + +**Interfaces:** +- Consumes: full agent build with ddprof profiling enabled. + +- [ ] **Step 1: Build the agent** + +Run: `./gradlew :dd-java-agent:shadowJar -PtestJvm=21` +Expected: BUILD SUCCESSFUL; jar in `dd-java-agent/build/libs/`. + +- [ ] **Step 2: Run existing virtual-thread / profiling smoke tests** + +Locate and run the virtual-thread smoke tests and any profiling smoke tests, on JDK 21+: + +Run: `./gradlew :dd-smoke-tests:... :test -PtestJvm=21` (identify the specific virtual-thread and profiling smoke-test modules under `dd-smoke-tests/`) +Expected: PASS. + +- [ ] **Step 3: Manual attribution check** + +With `DD_PROFILING_ENABLED=true` and `DD_PROFILING_DDPROF_ENABLED=true`, run a small app that does traced blocking work on virtual threads (many park/unpark cycles across carriers). Confirm in the profile that on-CPU/wall samples occurring inside the traced work are attributed to the expected span, and that carrier threads running *other* work are not mis-attributed to a virtual thread's span. Record the result in the PR description. + +Note: this step is not automated because ddprof requires the native profiler library and a real profiling run; treat it as a required manual gate before marking the PR ready. + +--- + +### Task 7: Full verification and PR prep + +**Files:** +- No source change. + +- [ ] **Step 1: Run the affected module test suites** + +```bash +./gradlew :internal-api:test :dd-trace-core:test -PtestJvm=21 +./gradlew :dd-java-agent:instrumentation:java:java-lang:java-lang-21.0:test -PtestJvm=21 +./gradlew :dd-java-agent:instrumentation:java:java-concurrent:java-concurrent-21.0:test -PtestJvm=21 +``` +Expected: all PASS. (The java-concurrent run guards the TaskRunner/StructuredTaskScope paths that share the scope manager, even though they are unchanged.) + +- [ ] **Step 2: Re-run the JMH benchmark to confirm the win** + +Run: `./gradlew :dd-trace-core:jmh -Pjmh.includes=VirtualThreadContextBenchmark -PtestJvm=21 -Pjmh.profilers=gc` +Expected: `proposed*` rows show ~0 B/op; `currentCycle_*` rows show 176 B/op (profiling off) / 288 B/op (profiling on). Confirms the redesigned path is allocation-free. + +- [ ] **Step 3: Spotless check** + +Run: `./gradlew spotlessCheck` +Expected: PASS. + +- [ ] **Step 4: Run `/techdebt` on the branch** + +Per repo workflow, run `/techdebt` to check for duplication/complexity in the branch changes before opening the PR. + +- [ ] **Step 5: Open a draft PR** + +Title (imperative, user-visible): `Reduce virtual-thread context-propagation overhead on park/unpark`. Labels: `tag: ai generated`, `comp: core` (or the java-lang instrumentation label), a `type:` label. Body should summarize the seed-once redesign, cite the before/after JMH numbers, and record the Task 6 profiler-attribution result. Open as draft first. + +--- + +## Self-Review + +**Spec coverage:** +- Seed-once scope stack → Task 4 (`VirtualThreadState.onMount` first-mount `swap()`). +- Drop per-cycle swap → Task 4 (no swap-out on unmount; no swap-in on remount). +- Carrier-aware profiler rebind/unbind → Tasks 1–3 (capability flag + manager methods + tracer exposure), invoked in Task 4. +- No-op when profiling off / not carrier-bound → Task 1 flag + Task 2 `profilerCarrierBound` gate; verified in Task 2 second test and Task 3 first test. +- JFR must not rebind → Task 1 (JFR inherits default `false`); ddprof-only override. +- Continuation lifetime unchanged → Task 4 (`onTerminate` unchanged; constructor signature unchanged). +- Field-injection risk (spec risk #1) → resolved during planning (store is field-injected); no task needed, noted here. +- Profiler attribution validation (spec risk #2) → Task 6. +- Testing plan (existing suite green, migration test, benchmark guard) → Tasks 4, 5, 7. +- Out-of-scope TaskRunner/StructuredTaskScope untouched → confirmed; Task 7 step 1 runs their suite as a guard. + +**Placeholder scan:** No TBD/TODO. Task 6 step 2 requires the executor to identify the specific smoke-test module path (the repo has many under `dd-smoke-tests/`); this is a lookup, not a design gap. Task 3 step 1 notes a visibility fallback for `NoopTracerAPI.INSTANCE`. + +**Type consistency:** Method names consistent across tasks — `isCarrierThreadBound()` (Task 1 → gated in Task 2), `rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()` (Task 2 manager → Task 3 tracer → Task 4 caller), `deactivateProfiling()` (Task 2 only). `VirtualThreadState(Context, Continuation)` constructor signature preserved so Task 4 needs no advice change. diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java index 14a5b5d6d21..825a1279feb 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java @@ -390,6 +390,12 @@ default AgentSpan blackholeSpan() { ProfilingContextIntegration getProfilingContext(); + /** Called by virtual-thread instrumentation on mount; see {@code ContinuableScopeManager}. */ + default void rebindProfilingContextToCarrier() {} + + /** Called by virtual-thread instrumentation on unmount; see {@code ContinuableScopeManager}. */ + default void unbindProfilingContextFromCarrier() {} + /** * Sets the new service name to be used as a default. * diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java index 4accced983a..9979dc7daa5 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java @@ -18,6 +18,16 @@ default void onAttach() {} /** Invoked when a thread exits */ default void onDetach() {} + /** + * Whether this integration stores the active span context in a carrier / OS-thread-keyed slot + * (e.g. ddprof's native {@code setContext}) rather than a virtual-thread-aware Java {@code + * ThreadLocal}. When {@code true}, the context must be re-applied to the current carrier on every + * virtual-thread mount and cleared on unmount. + */ + default boolean isCarrierThreadBound() { + return false; + } + default Stateful newScopeState(ProfilerContext profilerContext) { return Stateful.DEFAULT; } diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java new file mode 100644 index 00000000000..88a267e4400 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java @@ -0,0 +1,39 @@ +package datadog.trace.bootstrap.instrumentation.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import datadog.trace.api.EndpointTracker; +import datadog.trace.api.profiling.Timing; +import org.junit.jupiter.api.Test; + +class ProfilingContextIntegrationTest { + @Test + void defaultIntegrationIsNotCarrierThreadBound() { + ProfilingContextIntegration integration = + new ProfilingContextIntegration() { + @Override + public String name() { + return "test-only"; + } + + @Override + public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} + + @Override + public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { + return EndpointTracker.NO_OP; + } + + @Override + public Timing start(TimerType type) { + return Timing.NoOp.INSTANCE; + } + }; + assertFalse(integration.isCarrierThreadBound()); + } + + @Test + void noOpIntegrationIsNotCarrierThreadBound() { + assertFalse(ProfilingContextIntegration.NoOp.INSTANCE.isCarrierThreadBound()); + } +} From b05f03024bbc598a35f2ed8f09e2a5d1ede25846 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Thu, 9 Jul 2026 15:22:38 +0200 Subject: [PATCH 2/7] Preserve old implementation when legacy manager is disabled --- .../java/lang/VirtualThreadState.java | 53 +++++--- .../ddprof/DatadogProfilingIntegration.java | 9 +- .../jdk21/VirtualThreadInstrumentation.java | 13 +- ...ualThreadApiInstrumentationForkedTest.java | 11 ++ .../java/datadog/trace/core/CoreTracer.java | 10 -- .../core/scopemanager/ContinuableScope.java | 17 --- .../scopemanager/ContinuableScopeManager.java | 31 ----- .../core/CoreTracerCarrierRebindTest.java | 44 ------- .../CarrierProfilerRebindTest.java | 123 ------------------ .../instrumentation/api/AgentTracer.java | 6 - .../api/ProfilingContextIntegration.java | 16 ++- .../api/ProfilingContextIntegrationTest.java | 58 +++++---- 12 files changed, 103 insertions(+), 288 deletions(-) create mode 100644 dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationForkedTest.java delete mode 100644 dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java delete mode 100644 dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java index feb44aa3535..ad75a2e9194 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java @@ -1,44 +1,63 @@ package datadog.trace.bootstrap.instrumentation.java.lang; import datadog.context.Context; +import datadog.trace.api.InstrumenterConfig; import datadog.trace.bootstrap.instrumentation.api.AgentScope.Continuation; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; /** - * The virtual thread's scope stack lives in a virtual-thread-aware {@code ThreadLocal}, so it is - * seeded once on the first mount and then follows the thread across park/unpark and carrier - * migration on its own. ddprof's profiler context is keyed by carrier thread instead, so it is - * re-bound on mount and cleared on unmount when a carrier-bound profiling integration is active. + * Holds the context and continuation for a virtual thread. + * + *

    With the legacy context manager, {@code swap()} rebuilds the whole scope stack on every + * mount/unmount, which is costly on the virtual-thread park/unpark hot path. There the context is + * seeded once on the first mount — it then follows the thread across park/unpark and carrier + * migration via its virtual-thread-aware {@code ThreadLocal} scope stack — and the profiler context + * (which is keyed by carrier thread) is re-applied on each subsequent mount and cleared on unmount. + * + *

    With the new context manager {@code swap()} is cheap and drives the profiler through its + * context listener, so we simply swap in on mount and out on unmount. */ public final class VirtualThreadState { - private Context seedContext; - // Keeps the enclosing trace alive until the virtual thread finishes. + private static final boolean LEGACY_CONTEXT_MANAGER = + InstrumenterConfig.get().isLegacyContextManagerEnabled(); + + private Context context; private final Continuation continuation; + private Context previousContext; private boolean seeded; - public VirtualThreadState(Context seedContext, Continuation continuation) { - this.seedContext = seedContext; + public VirtualThreadState(Context context, Continuation continuation) { + this.context = context; this.continuation = continuation; } public void onMount() { - if (!seeded) { - // First mount also applies the profiler context to the carrier. - seedContext.swap(); - seeded = true; - seedContext = null; + if (LEGACY_CONTEXT_MANAGER) { + if (!seeded) { + // First mount also applies the profiler context to the carrier. + context.swap(); + seeded = true; + context = null; + } else { + AgentTracer.get().getProfilingContext().setContext(Context.current()); + } } else { - AgentTracer.get().rebindProfilingContextToCarrier(); + previousContext = context.swap(); } } public void onUnmount() { - AgentTracer.get().unbindProfilingContextFromCarrier(); + if (LEGACY_CONTEXT_MANAGER) { + AgentTracer.get().getProfilingContext().clearContext(); + } else if (previousContext != null) { + context = previousContext.swap(); + previousContext = null; + } } public void onTerminate() { - if (this.continuation != null) { - this.continuation.cancel(); + if (continuation != null) { + continuation.cancel(); } } } diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java index 0c7942ae957..f2105c64b0d 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java @@ -1,5 +1,6 @@ package com.datadog.profiling.ddprof; +import datadog.context.Context; import datadog.trace.api.EndpointTracker; import datadog.trace.api.Stateful; import datadog.trace.api.profiling.ProfilingContextAttribute; @@ -79,10 +80,14 @@ public String name() { } @Override - public boolean isCarrierThreadBound() { - return true; + public void setContext(Context context) { + AgentSpan span = AgentSpan.fromContext(context); + if (span != null) { + contextManager.activate(span.spanContext()); + } } + @Override public void clearContext() { DDPROF.clearSpanContext(); DDPROF.clearContextValue(SPAN_NAME_INDEX); diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java index b700d78d34e..3bfd6c043b2 100644 --- a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java @@ -40,12 +40,13 @@ *

      *
    1. {@code init()}: captures the current {@link Context} and an {@link Continuation} to prevent * the enclosing context scope from completing early. - *
    2. {@code mount()}: on the first mount, seeds the virtual thread's scope stack with the - * captured context; on later mounts, re-applies the profiler context to the current carrier - * (no-op unless carrier-bound profiling is active). - *
    3. {@code unmount()}: clears the carrier's profiler context (no-op unless carrier-bound - * profiling is active). The scope stack is NOT swapped out — it lives in the virtual thread's - * own thread-local and follows it across park/unpark and carrier migration. + *
    4. {@code mount()}: with the legacy context manager, seeds the virtual thread's scope stack + * with the captured context on the first mount only (it then follows the thread via its + * virtual-thread-aware thread-local) and re-applies the profiler context on later mounts; + * with the new context manager, swaps the context in on every mount (cheap, and drives the + * profiler through its context listener). + *
    5. {@code unmount()}: with the legacy context manager, clears the carrier's profiler context; + * with the new context manager, swaps the carrier's context back. *
    6. Steps 2-3 repeat on each park/unpark cycle, potentially on different carrier threads. *
    7. {@code afterDone()} / {@code afterTerminate()} for early VirtualThread support: cancels the * help continuation, releasing the context scope to be closed. diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationForkedTest.java b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationForkedTest.java new file mode 100644 index 00000000000..bedb14f10b3 --- /dev/null +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationForkedTest.java @@ -0,0 +1,11 @@ +package testdog.trace.instrumentation.java.lang.jdk21; + +import datadog.trace.junit.utils.config.WithConfig; + +/** + * Runs the {@link VirtualThreadApiInstrumentationTest} cases with the legacy context manager + * disabled, so {@code VirtualThreadState} takes the swap path instead of seed-once. Forked because + * the legacy-context-manager choice is captured once per JVM. + */ +@WithConfig(key = "legacy.context-manager.enabled", value = "false") +class VirtualThreadApiInstrumentationForkedTest extends VirtualThreadApiInstrumentationTest {} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index 7674ee4c075..75513b1b01e 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -1438,16 +1438,6 @@ public void addScopeListener(final ScopeListener listener) { this.scopeManager.addScopeListener(listener); } - @Override - public void rebindProfilingContextToCarrier() { - scopeManager.rebindProfilingContextToCarrier(); - } - - @Override - public void unbindProfilingContextFromCarrier() { - scopeManager.unbindProfilingContextFromCarrier(); - } - @Override public SubscriptionService getSubscriptionService(RequestContextSlot slot) { return (SubscriptionService) instrumentationGateway.getCallbackProvider(slot); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java b/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java index 9510e8629d8..ba7e5288a68 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java @@ -177,23 +177,6 @@ public final void beforeActivated() { } } - /** - * Clears the profiler context for this scope's state without closing the scope itself. The scope - * state must be idempotent and reusable, as this may be followed by another {@link - * #beforeActivated()} on the same scope (e.g. across virtual-thread unmount/remount). - */ - public final void clearProfilingContext() { - if (scopeState == Stateful.DEFAULT) { - return; - } - try { - scopeState.close(); - } catch (Throwable e) { - ContinuableScopeManager.ratelimitedLog.warn( - "ScopeState {} threw exception in clearProfilingContext()", scopeState.getClass(), e); - } - } - public final void afterActivated() { boolean hasScopeListeners = !scopeManager.scopeListeners.isEmpty(); boolean hasExtendedListeners = !scopeManager.extendedScopeListeners.isEmpty(); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java b/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java index b07ead3a400..04fbd637bb3 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java @@ -62,7 +62,6 @@ public final class ContinuableScopeManager implements ContextManager { final HealthMetrics healthMetrics; private final ProfilingContextIntegration profilingContextIntegration; private final boolean profilingEnabled; - private final boolean profilerCarrierBound; private final boolean hasDepthLimit; /** @@ -96,8 +95,6 @@ public ContinuableScopeManager( this.profilingContextIntegration = profilingContextIntegration; this.profilingEnabled = !(profilingContextIntegration instanceof ProfilingContextIntegration.NoOp); - this.profilerCarrierBound = - this.profilingEnabled && profilingContextIntegration.isCarrierThreadBound(); ContextManager.register(this); } @@ -373,34 +370,6 @@ ScopeStack scopeStack() { return this.tlsScopeStack.get(); } - /** - * Re-applies the active scope's profiler context to the current carrier; no-op unless a - * carrier-bound profiling integration is active. - */ - public void rebindProfilingContextToCarrier() { - if (!profilerCarrierBound) { - return; - } - final ContinuableScope active = scopeStack().active(); - if (active != null) { - active.beforeActivated(); - } - } - - /** - * Clears the current carrier's profiler context; no-op unless a carrier-bound profiling - * integration is active. - */ - public void unbindProfilingContextFromCarrier() { - if (!profilerCarrierBound) { - return; - } - final ContinuableScope active = scopeStack().active(); - if (active != null) { - active.clearProfilingContext(); - } - } - @Override public Context current() { final ContinuableScope active = scopeStack().active(); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java b/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java deleted file mode 100644 index 9a815ab4839..00000000000 --- a/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package datadog.trace.core; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; - -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; -import datadog.trace.bootstrap.instrumentation.api.AgentTracer; -import org.junit.jupiter.api.Test; - -class CoreTracerCarrierRebindTest { - - @Test - void tracerRebindUnbindAreSafeNoOpsWithoutProfiling() { - CoreTracer tracer = CoreTracer.builder().build(); - AgentSpan span = tracer.startSpan("test", "op"); - try (AgentScopeCloser ignored = new AgentScopeCloser(tracer.activateSpan(span))) { - // Profiling is disabled by default, so both calls must be harmless no-ops. - assertDoesNotThrow(tracer::rebindProfilingContextToCarrier); - assertDoesNotThrow(tracer::unbindProfilingContextFromCarrier); - } finally { - span.finish(); - } - } - - @Test - void noopTracerRebindUnbindDoNotThrow() { - AgentTracer.TracerAPI noop = AgentTracer.NOOP_TRACER; - assertDoesNotThrow(noop::rebindProfilingContextToCarrier); - assertDoesNotThrow(noop::unbindProfilingContextFromCarrier); - } - - /** Minimal try-with-resources helper around AgentScope. */ - static final class AgentScopeCloser implements AutoCloseable { - private final datadog.trace.bootstrap.instrumentation.api.AgentScope scope; - - AgentScopeCloser(datadog.trace.bootstrap.instrumentation.api.AgentScope scope) { - this.scope = scope; - } - - @Override - public void close() { - scope.close(); - } - } -} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java b/dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java deleted file mode 100644 index fd1f705f0f4..00000000000 --- a/dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package datadog.trace.core.scopemanager; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import datadog.context.ContextScope; -import datadog.trace.api.EndpointTracker; -import datadog.trace.api.Stateful; -import datadog.trace.api.profiling.ProfilingContextAttribute; -import datadog.trace.api.profiling.ProfilingScope; -import datadog.trace.api.profiling.Timer.TimerType; -import datadog.trace.api.profiling.Timing; -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; -import datadog.trace.bootstrap.instrumentation.api.ProfilerContext; -import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; -import datadog.trace.core.CoreTracer; -import datadog.trace.core.monitor.HealthMetrics; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.Test; - -class CarrierProfilerRebindTest { - - static final CoreTracer TRACER = CoreTracer.builder().build(); - - /** Records activate/close counts so the test can observe carrier rebind/unbind. */ - static final class CountingProfiling implements ProfilingContextIntegration { - final AtomicInteger activations = new AtomicInteger(); - final AtomicInteger closes = new AtomicInteger(); - private final boolean carrierBound; - - CountingProfiling(boolean carrierBound) { - this.carrierBound = carrierBound; - } - - @Override - public boolean isCarrierThreadBound() { - return carrierBound; - } - - @Override - public Stateful newScopeState(ProfilerContext profilerContext) { - return new Stateful() { - @Override - public void activate(Object context) { - activations.incrementAndGet(); - } - - @Override - public void close() { - closes.incrementAndGet(); - } - }; - } - - @Override - public String name() { - return "counting"; - } - - @Override - public ProfilingContextAttribute createContextAttribute(String attribute) { - return ProfilingContextAttribute.NoOp.INSTANCE; - } - - @Override - public ProfilingScope newScope() { - return ProfilingScope.NO_OP; - } - - @Override - public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} - - @Override - public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { - return EndpointTracker.NO_OP; - } - - @Override - public Timing start(TimerType type) { - return Timing.NoOp.INSTANCE; - } - } - - @Test - void rebindAndUnbindDriveProfilerWhenCarrierBound() { - CountingProfiling profiling = new CountingProfiling(true); - ContinuableScopeManager manager = - new ContinuableScopeManager(0, false, profiling, HealthMetrics.NO_OP); - AgentSpan span = TRACER.startSpan("test", "op"); - try (ContextScope scope = manager.attach(span)) { - // attach already activated the profiler once; measure only rebind/unbind from here. - profiling.activations.set(0); - profiling.closes.set(0); - - manager.rebindProfilingContextToCarrier(); - manager.unbindProfilingContextFromCarrier(); - - assertEquals(1, profiling.activations.get(), "rebind should re-apply profiler context"); - assertEquals(1, profiling.closes.get(), "unbind should clear profiler context"); - } finally { - span.finish(); - } - } - - @Test - void rebindAndUnbindAreNoOpWhenNotCarrierBound() { - CountingProfiling profiling = new CountingProfiling(false); - ContinuableScopeManager manager = - new ContinuableScopeManager(0, false, profiling, HealthMetrics.NO_OP); - AgentSpan span = TRACER.startSpan("test", "op"); - try (ContextScope scope = manager.attach(span)) { - profiling.activations.set(0); - profiling.closes.set(0); - - manager.rebindProfilingContextToCarrier(); - manager.unbindProfilingContextFromCarrier(); - - assertEquals(0, profiling.activations.get(), "no rebind when not carrier bound"); - assertEquals(0, profiling.closes.get(), "no unbind when not carrier bound"); - } finally { - span.finish(); - } - } -} diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java index 825a1279feb..14a5b5d6d21 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java @@ -390,12 +390,6 @@ default AgentSpan blackholeSpan() { ProfilingContextIntegration getProfilingContext(); - /** Called by virtual-thread instrumentation on mount; see {@code ContinuableScopeManager}. */ - default void rebindProfilingContextToCarrier() {} - - /** Called by virtual-thread instrumentation on unmount; see {@code ContinuableScopeManager}. */ - default void unbindProfilingContextFromCarrier() {} - /** * Sets the new service name to be used as a default. * diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java index 9979dc7daa5..cb35d520bda 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java @@ -1,5 +1,6 @@ package datadog.trace.bootstrap.instrumentation.api; +import datadog.context.Context; import datadog.trace.api.EndpointCheckpointer; import datadog.trace.api.EndpointTracker; import datadog.trace.api.Stateful; @@ -19,14 +20,15 @@ default void onAttach() {} default void onDetach() {} /** - * Whether this integration stores the active span context in a carrier / OS-thread-keyed slot - * (e.g. ddprof's native {@code setContext}) rather than a virtual-thread-aware Java {@code - * ThreadLocal}. When {@code true}, the context must be re-applied to the current carrier on every - * virtual-thread mount and cleared on unmount. + * Applies {@code context} to the current thread's profiler context. Default is a no-op: only + * integrations that key profiler context by the running (carrier) thread need this, and only when + * driven by the legacy context manager, where the virtual-thread instrumentation seeds the scope + * stack once and calls this on each subsequent mount rather than swapping. */ - default boolean isCarrierThreadBound() { - return false; - } + default void setContext(Context context) {} + + /** Clears the current thread's profiler context. See {@link #setContext(Context)}. */ + default void clearContext() {} default Stateful newScopeState(ProfilerContext profilerContext) { return Stateful.DEFAULT; diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java index 88a267e4400..8d16b5dd169 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java @@ -1,39 +1,47 @@ package datadog.trace.bootstrap.instrumentation.api; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import datadog.context.Context; import datadog.trace.api.EndpointTracker; import datadog.trace.api.profiling.Timing; import org.junit.jupiter.api.Test; class ProfilingContextIntegrationTest { + + private static ProfilingContextIntegration bareIntegration() { + return new ProfilingContextIntegration() { + @Override + public String name() { + return "test-only"; + } + + @Override + public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} + + @Override + public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { + return EndpointTracker.NO_OP; + } + + @Override + public Timing start(TimerType type) { + return Timing.NoOp.INSTANCE; + } + }; + } + @Test - void defaultIntegrationIsNotCarrierThreadBound() { - ProfilingContextIntegration integration = - new ProfilingContextIntegration() { - @Override - public String name() { - return "test-only"; - } - - @Override - public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} - - @Override - public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { - return EndpointTracker.NO_OP; - } - - @Override - public Timing start(TimerType type) { - return Timing.NoOp.INSTANCE; - } - }; - assertFalse(integration.isCarrierThreadBound()); + void defaultSetAndClearContextAreNoOps() { + ProfilingContextIntegration integration = bareIntegration(); + assertDoesNotThrow(() -> integration.setContext(Context.root())); + assertDoesNotThrow(integration::clearContext); } @Test - void noOpIntegrationIsNotCarrierThreadBound() { - assertFalse(ProfilingContextIntegration.NoOp.INSTANCE.isCarrierThreadBound()); + void noOpSetAndClearContextAreNoOps() { + ProfilingContextIntegration noOp = ProfilingContextIntegration.NoOp.INSTANCE; + assertDoesNotThrow(() -> noOp.setContext(Context.root())); + assertDoesNotThrow(noOp::clearContext); } } From acd37e673bde998b0b76536f5ac4e412be662066 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Thu, 9 Jul 2026 15:29:45 +0200 Subject: [PATCH 3/7] reduce diff --- .../java/lang/VirtualThreadState.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java index ad75a2e9194..767f0dc2720 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java @@ -21,9 +21,16 @@ public final class VirtualThreadState { private static final boolean LEGACY_CONTEXT_MANAGER = InstrumenterConfig.get().isLegacyContextManagerEnabled(); + /** The virtual thread's saved context (scope stack snapshot). */ private Context context; + + /** Prevents the enclosing context scope from completing before the virtual thread finishes. */ private final Continuation continuation; + + /** The carrier thread's saved context, set between mount and unmount. */ private Context previousContext; + + /** Determines whenever the context has been already set for the first mount */ private boolean seeded; public VirtualThreadState(Context context, Continuation continuation) { @@ -55,9 +62,10 @@ public void onUnmount() { } } + /** Called on termination: releases the trace continuation. */ public void onTerminate() { - if (continuation != null) { - continuation.cancel(); + if (this.continuation != null) { + this.continuation.cancel(); } } } From b48e02724fd113264d84376d80b0ab4e30933201 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Fri, 10 Jul 2026 11:13:25 +0200 Subject: [PATCH 4/7] various suggestinos --- .../java/lang/VirtualThreadState.java | 25 +- .../jdk21/VirtualThreadInstrumentation.java | 11 +- ...tual-thread-instrumentation-performance.md | 754 ------------------ 3 files changed, 15 insertions(+), 775 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-08-virtual-thread-instrumentation-performance.md diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java index 767f0dc2720..257b1ba9edb 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java @@ -10,8 +10,8 @@ * *

      With the legacy context manager, {@code swap()} rebuilds the whole scope stack on every * mount/unmount, which is costly on the virtual-thread park/unpark hot path. There the context is - * seeded once on the first mount — it then follows the thread across park/unpark and carrier - * migration via its virtual-thread-aware {@code ThreadLocal} scope stack — and the profiler context + * seeded once on the first mount; it then follows the thread across park/unpark and carrier + * migration via its virtual-thread-aware {@code ThreadLocal} scope stack, and the profiler context * (which is keyed by carrier thread) is re-applied on each subsequent mount and cleared on unmount. * *

      With the new context manager {@code swap()} is cheap and drives the profiler through its @@ -30,9 +30,6 @@ public final class VirtualThreadState { /** The carrier thread's saved context, set between mount and unmount. */ private Context previousContext; - /** Determines whenever the context has been already set for the first mount */ - private boolean seeded; - public VirtualThreadState(Context context, Continuation continuation) { this.context = context; this.continuation = continuation; @@ -40,10 +37,9 @@ public VirtualThreadState(Context context, Continuation continuation) { public void onMount() { if (LEGACY_CONTEXT_MANAGER) { - if (!seeded) { + if (context != null) { // First mount also applies the profiler context to the carrier. - context.swap(); - seeded = true; + previousContext = context.swap(); context = null; } else { AgentTracer.get().getProfilingContext().setContext(Context.current()); @@ -54,15 +50,16 @@ public void onMount() { } public void onUnmount() { - if (LEGACY_CONTEXT_MANAGER) { - AgentTracer.get().getProfilingContext().clearContext(); - } else if (previousContext != null) { - context = previousContext.swap(); - previousContext = null; + if (previousContext != null) { + if (LEGACY_CONTEXT_MANAGER) { + AgentTracer.get().getProfilingContext().setContext(previousContext); + } else { + context = previousContext.swap(); + previousContext = null; + } } } - /** Called on termination: releases the trace continuation. */ public void onTerminate() { if (this.continuation != null) { this.continuation.cancel(); diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java index 3bfd6c043b2..4f40cc0ff50 100644 --- a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java @@ -40,13 +40,10 @@ *

        *
      1. {@code init()}: captures the current {@link Context} and an {@link Continuation} to prevent * the enclosing context scope from completing early. - *
      2. {@code mount()}: with the legacy context manager, seeds the virtual thread's scope stack - * with the captured context on the first mount only (it then follows the thread via its - * virtual-thread-aware thread-local) and re-applies the profiler context on later mounts; - * with the new context manager, swaps the context in on every mount (cheap, and drives the - * profiler through its context listener). - *
      3. {@code unmount()}: with the legacy context manager, clears the carrier's profiler context; - * with the new context manager, swaps the carrier's context back. + *
      4. {@code mount()}: swaps the virtual thread's saved context into the carrier thread, saving + * the carrier thread's context. + *
      5. {@code unmount()}: swaps the carrier thread's original context back, saving the virtual + * thread's (possibly modified) context for the next mount. *
      6. Steps 2-3 repeat on each park/unpark cycle, potentially on different carrier threads. *
      7. {@code afterDone()} / {@code afterTerminate()} for early VirtualThread support: cancels the * help continuation, releasing the context scope to be closed. diff --git a/docs/superpowers/plans/2026-07-08-virtual-thread-instrumentation-performance.md b/docs/superpowers/plans/2026-07-08-virtual-thread-instrumentation-performance.md deleted file mode 100644 index dea2adb5362..00000000000 --- a/docs/superpowers/plans/2026-07-08-virtual-thread-instrumentation-performance.md +++ /dev/null @@ -1,754 +0,0 @@ -# Virtual Thread Instrumentation Performance Redesign — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Stop swapping the whole scope stack on every virtual-thread park/unpark; seed the context once and, only when carrier-bound (ddprof) profiling is active, do an allocation-free profiler rebind/unbind per mount/unmount. - -**Architecture:** The trace scope stack lives in a native `ThreadLocal` that is virtual-thread-aware (follows the VT across park/unpark and carrier migration), so it needs seeding exactly once on first mount. The ddprof profiler context lives in a carrier-OS-thread-keyed native slot that is *not* VT-aware, so it needs a cheap re-apply on mount and clear on unmount — but only for integrations that report themselves carrier-bound. A new capability flag on `ProfilingContextIntegration` plus two new `TracerAPI`/`ContinuableScopeManager` methods keep ddprof knowledge in dd-trace-core; `VirtualThreadState` calls them. - -**Tech Stack:** Java, ByteBuddy advice (java-lang-21.0 instrumentation), JUnit 5, dd-trace-core scope manager. - -## Global Constraints - -- Instrumentation advice/helper code must compile to Java 8 bytecode; `VirtualThreadState` and `VirtualThreadInstrumentation` must not use APIs newer than the module allows. (Existing constraint — do not introduce new JDK 21 API usage in these files.) -- `VirtualThreadState` is a bootstrap helper (`agent-bootstrap`), reached from advice; it may use `datadog.context.Context`, `AgentTracer`, and `AgentScope.Continuation` (all bootstrap-visible). It must NOT reference ddprof/profiling implementation classes directly. -- New unit tests: JUnit 5 + Java. Instrumentation tests stay in the existing JUnit 5 Java suite under `testdog.trace.instrumentation.java.lang.jdk21`. -- Run java-lang-21.0 tests on JDK 21: `-PtestJvm=21`. -- Format with `./gradlew spotlessApply` before each commit. -- Do NOT auto-commit unless a step says to; the repo owner reviews diffs. (This plan includes commit steps; the executor should still surface diffs.) - -## File Structure - -- `internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java` — add `default boolean isCarrierThreadBound()`. -- `dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java` — override `isCarrierThreadBound()` → `true`. -- `dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java` — add `deactivateProfiling()`. -- `dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java` — add `profilerCarrierBound` field + `rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()`. -- `internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java` — add two `default` no-op methods to `TracerAPI`. -- `dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java` — override the two methods, delegating to `scopeManager`. -- `dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java` — rewrite to seed-once + rebind/unbind. -- `dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/.../VirtualThreadInstrumentation.java` — javadoc/comment update only (advice bodies unchanged). -- Tests: `dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java` (new), and additions to `.../java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java`. - ---- - -### Task 1: Carrier-bound capability flag on `ProfilingContextIntegration` - -**Files:** -- Modify: `internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java` -- Modify: `dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java` -- Test: `internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java` (new) - -**Interfaces:** -- Produces: `boolean ProfilingContextIntegration.isCarrierThreadBound()` — default `false`; `true` for ddprof. Used by Task 2 to gate the profiler rebind/unbind. - -- [ ] **Step 1: Write the failing test** - -Create `internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java`: - -```java -package datadog.trace.bootstrap.instrumentation.api; - -import static org.junit.jupiter.api.Assertions.assertFalse; - -import org.junit.jupiter.api.Test; - -class ProfilingContextIntegrationTest { - @Test - void defaultIntegrationIsNotCarrierThreadBound() { - ProfilingContextIntegration integration = () -> "test-only"; - assertFalse(integration.isCarrierThreadBound()); - } - - @Test - void noOpIntegrationIsNotCarrierThreadBound() { - assertFalse(ProfilingContextIntegration.NoOp.INSTANCE.isCarrierThreadBound()); - } -} -``` - -Note: `ProfilingContextIntegration` has a single abstract method `name()`, so `() -> "test-only"` is a valid lambda instance. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./gradlew :internal-api:test --tests 'datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegrationTest'` -Expected: FAIL to COMPILE — `cannot find symbol: method isCarrierThreadBound()`. - -- [ ] **Step 3: Add the default method** - -In `ProfilingContextIntegration.java`, add immediately after the `onDetach()` default method (around line 19): - -```java - /** - * Whether this integration stores the active span context in a carrier / OS-thread-keyed slot - * (e.g. ddprof's native {@code setContext}) rather than a virtual-thread-aware Java {@code - * ThreadLocal}. When {@code true}, the context must be re-applied to the current carrier on every - * virtual-thread mount and cleared on unmount. - */ - default boolean isCarrierThreadBound() { - return false; - } -``` - -- [ ] **Step 4: Override in the ddprof integration** - -In `DatadogProfilingIntegration.java`, add this method (next to `name()`, around lines 76-79): - -```java - @Override - public boolean isCarrierThreadBound() { - return true; - } -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `./gradlew :internal-api:test --tests 'datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegrationTest'` -Expected: PASS (2 tests). - -- [ ] **Step 6: Format and commit** - -```bash -./gradlew spotlessApply -git add internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java \ - internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java \ - dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java -git commit -m "Add ProfilingContextIntegration.isCarrierThreadBound capability flag" -``` - ---- - -### Task 2: Profiler rebind/unbind on the scope manager - -**Files:** -- Modify: `dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java` -- Modify: `dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java` -- Test: `dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java` (new) - -**Interfaces:** -- Consumes: `ProfilingContextIntegration.isCarrierThreadBound()` (Task 1); existing package-private `ContinuableScopeManager.scopeStack()`, `ScopeStack.active()`, public `ContinuableScope.beforeActivated()`. -- Produces: - - `void ContinuableScope.deactivateProfiling()` — clears the scope's profiler state without closing the scope. - - `void ContinuableScopeManager.rebindProfilingContextToCarrier()` — re-applies the active scope's profiler context to the current carrier; no-op unless carrier-bound profiling is on. - - `void ContinuableScopeManager.unbindProfilingContextFromCarrier()` — clears the current carrier's profiler slot; same gating. - -- [ ] **Step 1: Write the failing test** - -Create `dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java`: - -```java -package datadog.trace.core.scopemanager; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import datadog.context.ContextScope; -import datadog.trace.api.Stateful; -import datadog.trace.api.profiling.ProfilingContextAttribute; -import datadog.trace.api.profiling.ProfilingScope; -import datadog.trace.api.profiling.Timer.TimerType; -import datadog.trace.api.profiling.Timing; -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; -import datadog.trace.bootstrap.instrumentation.api.EndpointTracker; -import datadog.trace.bootstrap.instrumentation.api.ProfilerContext; -import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; -import datadog.trace.core.CoreTracer; -import datadog.trace.core.monitor.HealthMetrics; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.Test; - -class CarrierProfilerRebindTest { - - static final CoreTracer TRACER = CoreTracer.builder().build(); - - /** Records activate/close counts so the test can observe carrier rebind/unbind. */ - static final class CountingProfiling implements ProfilingContextIntegration { - final AtomicInteger activations = new AtomicInteger(); - final AtomicInteger closes = new AtomicInteger(); - private final boolean carrierBound; - - CountingProfiling(boolean carrierBound) { - this.carrierBound = carrierBound; - } - - @Override - public boolean isCarrierThreadBound() { - return carrierBound; - } - - @Override - public Stateful newScopeState(ProfilerContext profilerContext) { - return new Stateful() { - @Override - public void activate(Object context) { - activations.incrementAndGet(); - } - - @Override - public void close() { - closes.incrementAndGet(); - } - }; - } - - @Override - public String name() { - return "counting"; - } - - @Override - public ProfilingContextAttribute createContextAttribute(String attribute) { - return ProfilingContextAttribute.NoOp.INSTANCE; - } - - @Override - public ProfilingScope newScope() { - return ProfilingScope.NO_OP; - } - - @Override - public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} - - @Override - public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { - return EndpointTracker.NO_OP; - } - - @Override - public Timing start(TimerType type) { - return Timing.NoOp.INSTANCE; - } - } - - @Test - void rebindAndUnbindDriveProfilerWhenCarrierBound() { - CountingProfiling profiling = new CountingProfiling(true); - ContinuableScopeManager manager = - new ContinuableScopeManager(0, false, profiling, HealthMetrics.NO_OP); - AgentSpan span = TRACER.startSpan("test", "op"); - try (ContextScope scope = manager.attach(span)) { - // attach already activated the profiler once; measure only rebind/unbind from here. - profiling.activations.set(0); - profiling.closes.set(0); - - manager.rebindProfilingContextToCarrier(); - manager.unbindProfilingContextFromCarrier(); - - assertEquals(1, profiling.activations.get(), "rebind should re-apply profiler context"); - assertEquals(1, profiling.closes.get(), "unbind should clear profiler context"); - } finally { - span.finish(); - } - } - - @Test - void rebindAndUnbindAreNoOpWhenNotCarrierBound() { - CountingProfiling profiling = new CountingProfiling(false); - ContinuableScopeManager manager = - new ContinuableScopeManager(0, false, profiling, HealthMetrics.NO_OP); - AgentSpan span = TRACER.startSpan("test", "op"); - try (ContextScope scope = manager.attach(span)) { - profiling.activations.set(0); - profiling.closes.set(0); - - manager.rebindProfilingContextToCarrier(); - manager.unbindProfilingContextFromCarrier(); - - assertEquals(0, profiling.activations.get(), "no rebind when not carrier bound"); - assertEquals(0, profiling.closes.get(), "no unbind when not carrier bound"); - } finally { - span.finish(); - } - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./gradlew :dd-trace-core:test --tests 'datadog.trace.core.scopemanager.CarrierProfilerRebindTest'` -Expected: FAIL to COMPILE — `cannot find symbol: method rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()`. - -- [ ] **Step 3: Add `deactivateProfiling()` to `ContinuableScope`** - -In `ContinuableScope.java`, add after `beforeActivated()` (after line 178): - -```java - /** Clears the profiler context for this scope's state without closing the scope itself. */ - public final void deactivateProfiling() { - if (scopeState == Stateful.DEFAULT) { - return; - } - try { - scopeState.close(); - } catch (Throwable e) { - ContinuableScopeManager.ratelimitedLog.warn( - "ScopeState {} threw exception in deactivateProfiling()", scopeState.getClass(), e); - } - } -``` - -- [ ] **Step 4: Add the field and methods to `ContinuableScopeManager`** - -In `ContinuableScopeManager.java`, add a field next to `profilingEnabled` (after line 64): - -```java - private final boolean profilerCarrierBound; -``` - -In the 4-arg constructor, immediately after the `this.profilingEnabled = ...` assignment (after line 97), add: - -```java - this.profilerCarrierBound = - this.profilingEnabled && profilingContextIntegration.isCarrierThreadBound(); -``` - -Add these two public methods near `scopeStack()` (after line 371): - -```java - /** - * Re-applies the active scope's profiler context to the current carrier thread. No-op unless a - * carrier-thread-bound profiling integration (e.g. ddprof) is active. Called on virtual-thread - * mount, where the carrier's native context slot must be refreshed after (possibly) migrating - * carriers. - */ - public void rebindProfilingContextToCarrier() { - if (!profilerCarrierBound) { - return; - } - final ContinuableScope active = scopeStack().active(); - if (active != null) { - active.beforeActivated(); - } - } - - /** - * Clears the current carrier thread's profiler context slot. No-op unless a carrier-thread-bound - * profiling integration is active. Called on virtual-thread unmount so the carrier is not left - * attributed to the virtual thread's span. - */ - public void unbindProfilingContextFromCarrier() { - if (!profilerCarrierBound) { - return; - } - final ContinuableScope active = scopeStack().active(); - if (active != null) { - active.deactivateProfiling(); - } - } -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `./gradlew :dd-trace-core:test --tests 'datadog.trace.core.scopemanager.CarrierProfilerRebindTest'` -Expected: PASS (2 tests). - -- [ ] **Step 6: Format and commit** - -```bash -./gradlew spotlessApply -git add dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScope.java \ - dd-trace-core/src/main/java/datadog/trace/core/scopemanager/ContinuableScopeManager.java \ - dd-trace-core/src/test/java/datadog/trace/core/scopemanager/CarrierProfilerRebindTest.java -git commit -m "Add carrier profiler rebind/unbind to ContinuableScopeManager" -``` - ---- - -### Task 3: Expose rebind/unbind on `TracerAPI` and `CoreTracer` - -**Files:** -- Modify: `internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java` -- Modify: `dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java` -- Test: `dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java` (new) - -**Interfaces:** -- Consumes: `ContinuableScopeManager.rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()` (Task 2). -- Produces: `void TracerAPI.rebindProfilingContextToCarrier()` and `void TracerAPI.unbindProfilingContextFromCarrier()` — `default` no-ops on the interface, delegating overrides on `CoreTracer`. Used by Task 4 via `AgentTracer.get()`. - -- [ ] **Step 1: Write the failing test** - -Create `dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java`: - -```java -package datadog.trace.core; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; - -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; -import datadog.trace.bootstrap.instrumentation.api.AgentTracer; -import org.junit.jupiter.api.Test; - -class CoreTracerCarrierRebindTest { - - @Test - void tracerRebindUnbindAreSafeNoOpsWithoutProfiling() { - CoreTracer tracer = CoreTracer.builder().build(); - AgentSpan span = tracer.startSpan("test", "op"); - try (AgentScopeCloser ignored = new AgentScopeCloser(tracer.activateSpan(span))) { - // Profiling is disabled by default, so both calls must be harmless no-ops. - assertDoesNotThrow(tracer::rebindProfilingContextToCarrier); - assertDoesNotThrow(tracer::unbindProfilingContextFromCarrier); - } finally { - span.finish(); - } - } - - @Test - void noopTracerRebindUnbindDoNotThrow() { - AgentTracer.TracerAPI noop = AgentTracer.NoopTracerAPI.INSTANCE; - assertDoesNotThrow(noop::rebindProfilingContextToCarrier); - assertDoesNotThrow(noop::unbindProfilingContextFromCarrier); - } - - /** Minimal try-with-resources helper around AgentScope. */ - static final class AgentScopeCloser implements AutoCloseable { - private final datadog.trace.bootstrap.instrumentation.api.AgentScope scope; - - AgentScopeCloser(datadog.trace.bootstrap.instrumentation.api.AgentScope scope) { - this.scope = scope; - } - - @Override - public void close() { - scope.close(); - } - } -} -``` - -Note: verify `AgentTracer.NoopTracerAPI.INSTANCE` is accessible from this package; the exploration found `NoopTracerAPI` is a static nested class in `AgentTracer`. If `INSTANCE` is not visible, replace the second test body with `assertDoesNotThrow(() -> ((AgentTracer.TracerAPI) AgentTracer.get()).rebindProfilingContextToCarrier())` after confirming the default provider — but prefer the explicit NoopTracerAPI reference if visible. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `./gradlew :dd-trace-core:test --tests 'datadog.trace.core.CoreTracerCarrierRebindTest'` -Expected: FAIL to COMPILE — `cannot find symbol: method rebindProfilingContextToCarrier()`. - -- [ ] **Step 3: Add default no-op methods to `TracerAPI`** - -In `AgentTracer.java`, inside the `TracerAPI` interface, add after `getProfilingContext()` (after line 391): - -```java - /** - * Re-applies the active scope's profiler context to the current carrier thread. No-op unless a - * carrier-thread-bound profiling integration is active. Used by virtual-thread instrumentation - * on mount. - */ - default void rebindProfilingContextToCarrier() {} - - /** - * Clears the current carrier thread's profiler context slot. No-op unless a carrier-thread-bound - * profiling integration is active. Used by virtual-thread instrumentation on unmount. - */ - default void unbindProfilingContextFromCarrier() {} -``` - -Because these are `default` methods, `NoopTracerAPI` needs no change. - -- [ ] **Step 4: Override in `CoreTracer` to delegate** - -In `CoreTracer.java`, add near the other scope delegations (the class has a `private final ContinuableScopeManager scopeManager;` field). Place after an existing scope-delegating method such as `addScopeListener`: - -```java - @Override - public void rebindProfilingContextToCarrier() { - scopeManager.rebindProfilingContextToCarrier(); - } - - @Override - public void unbindProfilingContextFromCarrier() { - scopeManager.unbindProfilingContextFromCarrier(); - } -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `./gradlew :dd-trace-core:test --tests 'datadog.trace.core.CoreTracerCarrierRebindTest'` -Expected: PASS (2 tests). - -- [ ] **Step 6: Format and commit** - -```bash -./gradlew spotlessApply -git add internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java \ - dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java \ - dd-trace-core/src/test/java/datadog/trace/core/CoreTracerCarrierRebindTest.java -git commit -m "Expose carrier profiler rebind/unbind on TracerAPI and CoreTracer" -``` - ---- - -### Task 4: Rewrite `VirtualThreadState` to seed-once + rebind/unbind - -**Files:** -- Modify: `dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java` -- Modify: `dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java` (javadoc only) -- Test: existing `.../java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java` and `VirtualThreadApiInstrumentationTest.java` (regression guard — no new file here; new migration test added in Task 5) - -**Interfaces:** -- Consumes: `AgentTracer.get().rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()` (Task 3); `Context.swap()`; existing constructor signature `VirtualThreadState(Context, AgentScope.Continuation)` — unchanged so `VirtualThreadInstrumentation$Construct` needs no change. -- Produces: `onMount()`, `onUnmount()`, `onTerminate()` (same names/signatures the advice already calls). - -- [ ] **Step 1: Confirm the regression baseline is green (pre-change)** - -Run: `./gradlew :dd-java-agent:instrumentation:java:java-lang:java-lang-21.0:test -PtestJvm=21` -Expected: PASS. This is the behavioral contract the rewrite must preserve. (If it fails before any change, stop and investigate the environment.) - -- [ ] **Step 2: Rewrite `VirtualThreadState.java`** - -Replace the entire file with: - -```java -package datadog.trace.bootstrap.instrumentation.java.lang; - -import datadog.context.Context; -import datadog.trace.bootstrap.instrumentation.api.AgentScope.Continuation; -import datadog.trace.bootstrap.instrumentation.api.AgentTracer; - -/** - * Holds the seed context and scope continuation for a virtual thread. - * - *

        Used by java-lang-21.0 {@code VirtualThreadInstrumentation}. The virtual thread's scope stack - * lives in a virtual-thread-aware {@code ThreadLocal}, so it only needs seeding once on the first - * mount; it then follows the virtual thread across park/unpark and carrier migration on its own. On - * subsequent mounts/unmounts the only per-cycle work is a lightweight profiler rebind/unbind, and - * only when a carrier-thread-bound profiling integration (ddprof) is active — otherwise those calls - * are no-ops. - */ -public final class VirtualThreadState { - /** The parent context captured at construction; installed once on first mount, then released. */ - private Context seedContext; - - /** Prevents the enclosing context scope from completing before the virtual thread finishes. */ - private final Continuation continuation; - - /** Whether the seed context has been installed into the virtual thread's scope stack. */ - private boolean seeded; - - public VirtualThreadState(Context seedContext, Continuation continuation) { - this.seedContext = seedContext; - this.continuation = continuation; - } - - /** - * Called on mount (running as the virtual thread). On the first mount, installs the seed context - * into the virtual thread's scope stack (this also applies the profiler context to the first - * carrier). On later mounts, re-applies the active scope's profiler context to the current - * carrier. - */ - public void onMount() { - if (!seeded) { - seedContext.swap(); - seeded = true; - seedContext = null; - } else { - AgentTracer.get().rebindProfilingContextToCarrier(); - } - } - - /** Called on unmount (running as the virtual thread): clears the carrier's profiler context. */ - public void onUnmount() { - AgentTracer.get().unbindProfilingContextFromCarrier(); - } - - /** Called on termination: releases the trace continuation. */ - public void onTerminate() { - if (this.continuation != null) { - this.continuation.cancel(); - } - } -} -``` - -- [ ] **Step 3: Update the instrumentation javadoc (no behavioral change)** - -In `VirtualThreadInstrumentation.java`, replace the class-level lifecycle javadoc (lines 33-60) description of mount/unmount so it reflects seed-once semantics. Change the `

      8. {@code mount()}` and `
      9. {@code unmount()}` bullets to: - -```java - *
      10. {@code mount()}: on the first mount, seeds the virtual thread's scope stack with the - * captured context; on later mounts, re-applies the profiler context to the current carrier - * (no-op unless carrier-bound profiling is active). - *
      11. {@code unmount()}: clears the carrier's profiler context (no-op unless carrier-bound - * profiling is active). The scope stack is NOT swapped out — it lives in the virtual thread's - * own thread-local and follows it across park/unpark and carrier migration. -``` - -Leave the advice classes (`Construct`, `Mount`, `Unmount`, `AfterDone`), `contextStore()`, `excludedClasses()`, and `preloadClassNames()` unchanged. - -- [ ] **Step 4: Run the regression suite to verify behavior is preserved** - -Run: `./gradlew :dd-java-agent:instrumentation:java:java-lang:java-lang-21.0:test -PtestJvm=21` -Expected: PASS — all existing `VirtualThreadLifeCycleTest` and `VirtualThreadApiInstrumentationTest` cases green (parent inheritance, context restored after remount, child-span ordering across unmount/remount, concurrent VTs, no-context VT). - -- [ ] **Step 5: Format and commit** - -```bash -./gradlew spotlessApply -git add dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java \ - dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/main/java/datadog/trace/instrumentation/java/lang/jdk21/VirtualThreadInstrumentation.java -git commit -m "Seed virtual-thread context once instead of swapping per mount/unmount" -``` - ---- - -### Task 5: Add a carrier-migration regression test - -**Files:** -- Modify: `dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java` - -**Interfaces:** -- Consumes: the rewritten `VirtualThreadState` behavior (Task 4). Uses the existing `AbstractInstrumentationTest`, `@Trace`, `GlobalTracer`, `span()`/`trace()` assertion DSL already imported in the file. - -- [ ] **Step 1: Write the failing-then-passing test** - -Add this method to `VirtualThreadLifeCycleTest` (before the private helpers at line 215). It forces a small carrier pool so virtual threads are highly likely to resume on different carriers, and asserts context is preserved and a child span is parented correctly across migration: - -```java - @DisplayName("test context preserved across carrier migration") - @Test - void testContextPreservedAcrossCarrierMigration() { - // Constrain the carrier pool so parked VTs resume on different carriers. - String previousParallelism = - System.getProperty("jdk.virtualThreadScheduler.parallelism"); - System.setProperty("jdk.virtualThreadScheduler.parallelism", "2"); - try { - int threadCount = 32; - String[] parentSpanId = new String[1]; - String[] childParentSpanIds = new String[threadCount]; - - new Runnable() { - @Override - @Trace(operationName = "parent") - public void run() { - parentSpanId[0] = GlobalTracer.get().getSpanId(); - List threads = new ArrayList<>(); - for (int i = 0; i < threadCount; i++) { - int index = i; - threads.add( - Thread.startVirtualThread( - () -> { - // Multiple park/unpark cycles to provoke carrier migration. - tryUnmount(); - childParentSpanIds[index] = GlobalTracer.get().getSpanId(); - })); - } - for (Thread thread : threads) { - try { - thread.join(TIMEOUT); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } - } - }.run(); - - for (int i = 0; i < threadCount; i++) { - assertEquals( - parentSpanId[0], - childParentSpanIds[i], - "context must survive park/unpark and carrier migration for VT #" + i); - } - assertTraces(trace(span().root().operationName("parent"))); - } finally { - if (previousParallelism == null) { - System.clearProperty("jdk.virtualThreadScheduler.parallelism"); - } else { - System.setProperty("jdk.virtualThreadScheduler.parallelism", previousParallelism); - } - } - } -``` - -Note: `jdk.virtualThreadScheduler.parallelism` is read when the default scheduler initializes. If the suite has already started virtual threads, this property may have no effect on the running scheduler; the test still validates propagation across many park/unpark cycles regardless. Keep the property set for best-effort migration. - -- [ ] **Step 2: Run the test** - -Run: `./gradlew :dd-java-agent:instrumentation:java:java-lang:java-lang-21.0:test -PtestJvm=21 --tests '*VirtualThreadLifeCycleTest'` -Expected: PASS, including the new `testContextPreservedAcrossCarrierMigration`. - -- [ ] **Step 3: Format and commit** - -```bash -./gradlew spotlessApply -git add dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadLifeCycleTest.java -git commit -m "Add carrier-migration regression test for virtual-thread context" -``` - ---- - -### Task 6: Verify profiler attribution end-to-end (manual/smoke) - -**Files:** -- No source change. Verification only. - -**Interfaces:** -- Consumes: full agent build with ddprof profiling enabled. - -- [ ] **Step 1: Build the agent** - -Run: `./gradlew :dd-java-agent:shadowJar -PtestJvm=21` -Expected: BUILD SUCCESSFUL; jar in `dd-java-agent/build/libs/`. - -- [ ] **Step 2: Run existing virtual-thread / profiling smoke tests** - -Locate and run the virtual-thread smoke tests and any profiling smoke tests, on JDK 21+: - -Run: `./gradlew :dd-smoke-tests:... :test -PtestJvm=21` (identify the specific virtual-thread and profiling smoke-test modules under `dd-smoke-tests/`) -Expected: PASS. - -- [ ] **Step 3: Manual attribution check** - -With `DD_PROFILING_ENABLED=true` and `DD_PROFILING_DDPROF_ENABLED=true`, run a small app that does traced blocking work on virtual threads (many park/unpark cycles across carriers). Confirm in the profile that on-CPU/wall samples occurring inside the traced work are attributed to the expected span, and that carrier threads running *other* work are not mis-attributed to a virtual thread's span. Record the result in the PR description. - -Note: this step is not automated because ddprof requires the native profiler library and a real profiling run; treat it as a required manual gate before marking the PR ready. - ---- - -### Task 7: Full verification and PR prep - -**Files:** -- No source change. - -- [ ] **Step 1: Run the affected module test suites** - -```bash -./gradlew :internal-api:test :dd-trace-core:test -PtestJvm=21 -./gradlew :dd-java-agent:instrumentation:java:java-lang:java-lang-21.0:test -PtestJvm=21 -./gradlew :dd-java-agent:instrumentation:java:java-concurrent:java-concurrent-21.0:test -PtestJvm=21 -``` -Expected: all PASS. (The java-concurrent run guards the TaskRunner/StructuredTaskScope paths that share the scope manager, even though they are unchanged.) - -- [ ] **Step 2: Re-run the JMH benchmark to confirm the win** - -Run: `./gradlew :dd-trace-core:jmh -Pjmh.includes=VirtualThreadContextBenchmark -PtestJvm=21 -Pjmh.profilers=gc` -Expected: `proposed*` rows show ~0 B/op; `currentCycle_*` rows show 176 B/op (profiling off) / 288 B/op (profiling on). Confirms the redesigned path is allocation-free. - -- [ ] **Step 3: Spotless check** - -Run: `./gradlew spotlessCheck` -Expected: PASS. - -- [ ] **Step 4: Run `/techdebt` on the branch** - -Per repo workflow, run `/techdebt` to check for duplication/complexity in the branch changes before opening the PR. - -- [ ] **Step 5: Open a draft PR** - -Title (imperative, user-visible): `Reduce virtual-thread context-propagation overhead on park/unpark`. Labels: `tag: ai generated`, `comp: core` (or the java-lang instrumentation label), a `type:` label. Body should summarize the seed-once redesign, cite the before/after JMH numbers, and record the Task 6 profiler-attribution result. Open as draft first. - ---- - -## Self-Review - -**Spec coverage:** -- Seed-once scope stack → Task 4 (`VirtualThreadState.onMount` first-mount `swap()`). -- Drop per-cycle swap → Task 4 (no swap-out on unmount; no swap-in on remount). -- Carrier-aware profiler rebind/unbind → Tasks 1–3 (capability flag + manager methods + tracer exposure), invoked in Task 4. -- No-op when profiling off / not carrier-bound → Task 1 flag + Task 2 `profilerCarrierBound` gate; verified in Task 2 second test and Task 3 first test. -- JFR must not rebind → Task 1 (JFR inherits default `false`); ddprof-only override. -- Continuation lifetime unchanged → Task 4 (`onTerminate` unchanged; constructor signature unchanged). -- Field-injection risk (spec risk #1) → resolved during planning (store is field-injected); no task needed, noted here. -- Profiler attribution validation (spec risk #2) → Task 6. -- Testing plan (existing suite green, migration test, benchmark guard) → Tasks 4, 5, 7. -- Out-of-scope TaskRunner/StructuredTaskScope untouched → confirmed; Task 7 step 1 runs their suite as a guard. - -**Placeholder scan:** No TBD/TODO. Task 6 step 2 requires the executor to identify the specific smoke-test module path (the repo has many under `dd-smoke-tests/`); this is a lookup, not a design gap. Task 3 step 1 notes a visibility fallback for `NoopTracerAPI.INSTANCE`. - -**Type consistency:** Method names consistent across tasks — `isCarrierThreadBound()` (Task 1 → gated in Task 2), `rebindProfilingContextToCarrier()` / `unbindProfilingContextFromCarrier()` (Task 2 manager → Task 3 tracer → Task 4 caller), `deactivateProfiling()` (Task 2 only). `VirtualThreadState(Context, Continuation)` constructor signature preserved so Task 4 needs no advice change. From 480efcf927152df7190a7ee0da3e5ad7f69f044e Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Fri, 10 Jul 2026 11:44:59 +0200 Subject: [PATCH 5/7] remove exposing clearContext on the interface --- .../datadog/profiling/ddprof/DatadogProfilingIntegration.java | 1 - .../instrumentation/api/ProfilingContextIntegration.java | 3 --- 2 files changed, 4 deletions(-) diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java index f2105c64b0d..0d9afc3f7df 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java @@ -87,7 +87,6 @@ public void setContext(Context context) { } } - @Override public void clearContext() { DDPROF.clearSpanContext(); DDPROF.clearContextValue(SPAN_NAME_INDEX); diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java index cb35d520bda..f2a74fee8ce 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegration.java @@ -27,9 +27,6 @@ default void onDetach() {} */ default void setContext(Context context) {} - /** Clears the current thread's profiler context. See {@link #setContext(Context)}. */ - default void clearContext() {} - default Stateful newScopeState(ProfilerContext profilerContext) { return Stateful.DEFAULT; } From 498ed03df5e0ed765d29c8e2d584477412205a38 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Fri, 10 Jul 2026 12:05:21 +0200 Subject: [PATCH 6/7] Adjust javadoc --- .../java/lang/VirtualThreadState.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java index 257b1ba9edb..d06c50f7e33 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/lang/VirtualThreadState.java @@ -8,11 +8,13 @@ /** * Holds the context and continuation for a virtual thread. * - *

        With the legacy context manager, {@code swap()} rebuilds the whole scope stack on every - * mount/unmount, which is costly on the virtual-thread park/unpark hot path. There the context is - * seeded once on the first mount; it then follows the thread across park/unpark and carrier - * migration via its virtual-thread-aware {@code ThreadLocal} scope stack, and the profiler context - * (which is keyed by carrier thread) is re-applied on each subsequent mount and cleared on unmount. + *

        The legacy context manager's {@code swap()} wraps the current scope stack together with the + * context so the original stack can be restored when the context is swapped back; doing that on + * every mount/unmount is costly on the virtual-thread park/unpark hot path. So instead the context + * is seeded once on the first mount, then follows the thread across park/unpark and carrier + * migration via its virtual-thread-aware {@code ThreadLocal} scope stack, while the profiler + * context (which is keyed by carrier thread) is re-applied on each subsequent mount and restored on + * unmount. * *

        With the new context manager {@code swap()} is cheap and drives the profiler through its * context listener, so we simply swap in on mount and out on unmount. From 430567e01e5c9421c84b55c176e51bb59402de87 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Fri, 10 Jul 2026 12:20:25 +0200 Subject: [PATCH 7/7] remove unuseful test --- .../api/ProfilingContextIntegrationTest.java | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java deleted file mode 100644 index 8d16b5dd169..00000000000 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/ProfilingContextIntegrationTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package datadog.trace.bootstrap.instrumentation.api; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; - -import datadog.context.Context; -import datadog.trace.api.EndpointTracker; -import datadog.trace.api.profiling.Timing; -import org.junit.jupiter.api.Test; - -class ProfilingContextIntegrationTest { - - private static ProfilingContextIntegration bareIntegration() { - return new ProfilingContextIntegration() { - @Override - public String name() { - return "test-only"; - } - - @Override - public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) {} - - @Override - public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { - return EndpointTracker.NO_OP; - } - - @Override - public Timing start(TimerType type) { - return Timing.NoOp.INSTANCE; - } - }; - } - - @Test - void defaultSetAndClearContextAreNoOps() { - ProfilingContextIntegration integration = bareIntegration(); - assertDoesNotThrow(() -> integration.setContext(Context.root())); - assertDoesNotThrow(integration::clearContext); - } - - @Test - void noOpSetAndClearContextAreNoOps() { - ProfilingContextIntegration noOp = ProfilingContextIntegration.NoOp.INSTANCE; - assertDoesNotThrow(() -> noOp.setContext(Context.root())); - assertDoesNotThrow(noOp::clearContext); - } -}