Repository navigation
Conversation
This comment has been minimized.
This comment has been minimized.
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
… 1.8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In legacy mode, every worker that runs a coroutine now swaps in the same coroutine-owned scope stack, instead of writing a copy back on restore. A resumed coroutine no longer depends on its previous worker finishing its restore first, and the stack keeps its full depth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Swapping out a shared stack now returns its existing handle instead of building a snapshot that takes the stack's lock, and swapping it in only takes the lock when a closed scope needs to be removed from the top. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A coroutine launched with its parent's job is a structured child, which completes before its parent. When the parent already keeps the child's trace open, the child no longer captures its own continuation, avoiding contended updates of the trace's pending reference count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coroutines built by coroutineScope, withContext and similar share their caller's element and complete before it, so children launched with their job are covered by the same trace continuation as the caller's own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SharedScopeContext no longer extends the saved-stack snapshot, so it holds just its stack. Both now extend ScopeStackContext, which is what swap() checks for, and ScopeContext is renamed SavedScopeContext and final again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
checkTop() and depth() only feed the close-error metric and the depth limit, and cleanup re-checks the stack under the lock, so they now read volatile copies kept up to date by the locked updates. Activating and closing a scope on a coroutine's stack takes one lock each instead of two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…routine When a coroutine's shared stack replaces a thread's stack, the coroutine always swaps it back on the same thread, so swap() now returns a token cached on that stack instead of a new snapshot on every activation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The seed and the parent's trace are only needed until the child captures its initial context, so clear them then; otherwise a long-lived child keeps its parent's scope stack and trace alive. Also explain why legacy mode doesn't save the coroutine's context on restore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The depth is now a plain field written just before the volatile top, so readers that read the top first also see its depth, saving a volatile store on every push and cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check for a kept-open trace before inspecting the child's job, read the element's context once per activation, and split the capture branches by whether there is a trace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Listeners such as the MDC log-correlation listener read the active span from afterScopeActivated. The shared stack only updated its volatile top after the inherited push/cleanup returned, so listeners saw the parent when a child was activated, and the closed child when the parent was reactivated. Copy push and cleanup into SharedScopeStack so the new top is published first; ScopeStack keeps its code unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reusing the top scope incremented its plain reference count outside any lock, and closing a scope decremented it the same way. While two workers briefly overlap on a coroutine's shared stack this could lose updates and remove a scope early, or retain a scope another thread had just removed. Route both through ScopeStack.retainTop/releaseScope. The plain stack keeps today's unlocked updates, inlined while it's the only implementation; the shared stack updates the count under its lock, and only retains a top scope that is still on top and alive, otherwise a new scope is pushed instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing a scope released it through whichever stack the closing thread had swapped in. A scope from a coroutine's shared stack closed after swapping away (such as by a context element restored after ours) skipped the owning stack's lock, so its count could still race with reuse there. Mark scopes when they're pushed onto a shared stack, and have marked scopes update their count under their own monitor, wherever they're closed. Reuse checks the scope is still alive under that monitor, which also makes the stack-level retainTop/releaseScope unnecessary, so they're removed. The flag fits in existing padding; scopes stay at 32 bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finishing overdue iteration scopes cleared their reference count outside the scope's monitor, so another thread retaining a shared scope at the same time could bring it back after it was cleared. Clear shared scopes under their monitor, like the other reference count changes. Also explain why alive() can read a shared scope's count without the lock: once it reaches zero it never rises again, so a stale read only delays removing a scope closed on another thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
More details
The completed static review raised no reportable defect in coroutine stack ownership or trace-continuation lifetimes. Runtime handoff and concurrency behavior remain unverified.
🤖 Bits Code Review · Commit 0c7bb96
|
I think I was able to follow what the code is doing, but the flow is hard to follow. To a large extent, I think that's inherent to the problem, but I also find that we don't have agreed upon terms to describe what we're doing. I had Claude map seed, owner, trace kept open into my own terms, so I could understand what's happening here. Since we're doing similar changes in a number of locations, I think it might be good to add some extra conveniences into the Context system to fit these situations. Claude was able to reproduce an issue around The scenario... And to be clear, adding the "macros" doesn't have to be part of this PR, I just think reviewing will remain hard until we have some common terms for these things. |
|
Reproducer for the scoped-coroutine issue above. With an empty Over four runs:
/**
* A child launched by its parent after a scoped coroutine in the parent has completed. The
* parent is started under a root span that finishes before the child runs.
*/
open fun structuredChildAfterScopedCoroutine(withScoped: Boolean): Int {
runTest {
childSpan("root").activateAndUse {
detached = launch(Job() + jobName("detached")) {
childSpan("seed").activateAndUse {
if (withScoped) {
withContext(jobName("scoped")) {}
}
launch(coroutineContext + jobName("child")) {
gate.await()
childSpan("child").finish()
}
}
}
}
}
return 3
}
def "child launched after a scoped coroutine keeps the trace open withScoped=#withScoped #dispatcherName"() {
setup:
CoreKotlinCoroutineTests kotlinTest = getCoreKotlinCoroutineTestsInstance(dispatcher)
int expectedNumberOfSpans = kotlinTest.structuredChildAfterScopedCoroutine(withScoped)
kotlinTest.finishDetached()
TEST_WRITER.waitForTraces(1)
List<DDSpan> trace = TEST_WRITER.get(0)
expect:
TEST_WRITER.size() == 1
trace.size() == expectedNumberOfSpans
findSpan(trace, "child").spanContext().getParentId() == findSpan(trace, "seed").spanContext().getSpanId()
findSpan(trace, "seed").spanContext().getParentId() == findSpan(trace, "root").spanContext().getSpanId()
where:
[dispatcherName, dispatcher, withScoped] << [dispatchersToTest, [true, false]].combinations().collect { it[0] + [it[1]] }
}Written by Claude, reviewed by @dougqh |
|
@dougqh actually the majority of changes here have nothing to do with the |
|
@dougqh @amarziali one option could be to declare that we cannot support Kotlin coroutines/suspensions properly with the legacy scope manager and instead pivot to replacing that as soon as we can with the new context manager. |
|
@dougqh @amarziali regarding the scoped-coroutine issue - I confirmed the issue and fixed it in f3f8dc8. Scoped coroutines share their caller’s Datadog element, so their completion must not release the owning coroutine’s continuation. Release now checks ownership, and owner completion also clears traceKeptOpen. Note: this issue predates this PR: running the regression tests with the previous element implementation also reproduces premature trace splitting. The PR’s continuation optimization relied on that existing lifetime accounting being correct. I also added coverage for a child launched after withContext, coroutineScope, or withTimeout completes, plus a control without a scoped coroutine. Both full suites pass on kotlinx 1.3 and 1.8. |
| } | ||
|
|
||
| @Nullable | ||
| private static AgentTraceCollector traceOf(Context context) { |
There was a problem hiding this comment.
Note that I've found the local root span id works equally well here for the local trace identity check, if we prefer not to rely on the collector itself.
What Does This Do
Stops Kotlin coroutines from losing Datadog scopes when they suspend on one thread and resume on another, with the legacy scope manager (the default).
In legacy mode each coroutine with its own Datadog element now uses one persistent scope stack. Whichever worker thread runs it swaps that same stack in to use for their work, so nothing is copied between workers and nothing is written back afterwards. Scoped coroutines can share their caller's element and stack.
DatadogThreadContextElementcaptures a handle to a newSharedScopeStackduring active-coroutine construction or lazy start, with a first-update fallback.updateThreadContextswaps it in;restoreThreadContextswaps the worker's own stack back and, in legacy mode, writes nothing back into the element.newCoroutineContextget a fresh element. A context passed tolaunch/asyncthat already holds a parent's element (e.g.launch(coroutineContext)) no longer makes independent parent and child coroutines share one element. Scoped coroutines such ascoroutineScopeandwithContextcan still share their caller's element. The fresh element starts from the parent's current context and keeps the old element's position in the context.How Kotlin hands a coroutine between threads, and why scopes got lost
ThreadContextElementis the kotlinx hook we use:updateThreadContext(ctx)runs on a worker just before it runs a slice of the coroutine (up to its next suspension).restoreThreadContext(ctx, oldState)runs on that same worker after the slice ends.On master the element works in two steps:
updateswaps the coroutine's saved context onto the worker.Context.swap()returns the worker's previous context.restoreswaps the worker's context back and saves what was current into the element (context = original.swap()), so scopes opened during the slice carry over to the next slice.This assumes slice N's
restorealways finishes before slice N+1'supdatestarts. kotlinx doesn't guarantee that.Deferred, aselect) and returningCOROUTINE_SUSPENDED.restoreThreadContext.So worker B can run
updateThreadContextbefore A has saved the stack:Two more effects of the legacy scope manager make this worse:
ScopeStack. When the saved context is restored on a different thread,ScopeContext.restore()builds a shallow copy holding only the top two scopes. Deeper stacks lose their base context on every hop between threads.launch(parentContext)the child reused the parent's element, so two coroutines that run at the same time read and write the same saved context. In the benchmark harness, master loses the expected span on essentially every resumption in that mode.The overlap is part of the
ThreadContextElementcontract; it does not by itself imply a bug in every element. Elements that install a fixed immutable context, such as OTel'sKotlinContextElement, do not depend on publishing mutations during restoration. The mutable legacy scope stack is the part this shared-stack design addresses.The approach in detail
1. One scope stack per coroutine (
SharedScopeStack)SharedScopeStackobject into their thread-local, by reference. B sees everything A's coroutine code did before suspending, because kotlinx's own resume already orders those writes before B runs. No suspension hooks and no copies are needed, and the stack keeps its full depth.restore. So:push,cleanup,clear, and the iteration-scope checks) aresynchronized;active(),top(),Context.current(),checkTop) read avolatile liveTopand take no lock;liveDepthis a plain int written just beforeliveTop, so a reader that readsliveTopfirst also sees its depth. It's only used to enforce the depth limit, so a slightly stale value is fine.SharedScopeStackhas its own copies ofpushandcleanup, which publish the new top before the activation listeners run. Listeners such as MDC log correlation read the active span fromafterScopeActivated.2. Swapping without disturbing code outside coroutines
ScopeStackbecomes non-final and gainstop(),swappedOut(),replacing()andswappedOutForSameThread().swap()uses double dispatch (newStack.replacing(oldStack)) rather than aninstanceof:SavedScopeContext(renamed fromScopeContext), exactly as before.ThreadScopeContext. It takes no snapshot, so no allocation is needed per slice.SharedScopeStackis loaded, HotSpot can inline the stack calls using class hierarchy analysis. After it is loaded, the measured HotSpot runs still inline the ordinary-stack path using a receiver type profile. Ordinary scopes also gain asharedflag check; the measured cost is described below rather than assumed to be zero.3. Scope lifetimes on a shared stack
shared. The flag fits in existing padding, soContinuableScopestays at 32 B.++/--.4. Fresh element per coroutine, in the same position
addDatadogElementcreates a new element for each instrumented builder call. When the supplied context already has one, the new element is seeded from that element's current context. In legacy mode that context is a handle to the parent's live stack; the child initializes its own stack from the current top.fold, so elements added after it can still update scopes on top of ours.5. Avoid a redundant continuation when the parent already keeps the trace open
Jobin the supplied builder context is the parent element's owner, or a scoped coroutine (coroutineScope,withContext,withTimeout, ...) sharing the parent's element;launch {}/async {}calls whose supplied argument lacks an inherited Datadog element still capture their own continuation when needed.6. One new
AgentTracermethod@Deprecated Context sharedContextStack(Context initial). The noop tracer returnsinitial.CoreTracerdelegates to the scope manager and throws in non-legacy mode, likeclosePrevious/activateNext.Removal once the legacy scope manager is gone
Delete:
SharedScopeStack,SharedScopeContext,ThreadScopeContextandContinuableScopeManager.sharedContextStack;AgentTracer.sharedContextStack, with its noop andCoreTracerversions;LEGACY_CONTEXT_MANAGERbranches;sharedflag and theretainShared/releaseShared/clearSharedmethods onContinuableScope.The non-final
ScopeStack,ScopeStackContextandSavedScopeContextgo away with the rest of the legacy package. The fresh element and the structured-child skip work in either mode, so they stay.Tests
dd-trace-core,SharedScopeStackTest(JUnit 5)kotlin-coroutines-1.3StandardSuspensionHandoffTest, run on kotlinx 1.3 and onlatestDepTest, which now runs on kotlinx 1.8. Scenarios:yield,delayandselect, with cancellation and nesting;coroutineScope,supervisorScope,withContextandwithTimeout, on return, throw and cancel;withContext;ThreadContextElements (1.8). The coroutine must always keep its base context. During the overlap window, other elements can briefly see each other's scopes, so restore-order violations are reported but allowed.AbstractKotlinCoroutineInstrumentationTestandCoreKotlinCoroutineTests:Job/SupervisorJob;AbstractDatadogThreadContextElementTest(@TableTest): replacing the element keeps the context's contents and the order ofThreadContextElementcallbacks, including with a dispatcher present and with custom context implementations.Motivation
Customers using Kotlin coroutines see spans with the wrong parent, or detached traces, once code suspends while a manual or instrumented span is active. Earlier fixes moved the window around rather than closing it. This approach removes the race in legacy mode, without depending on kotlinx internals.
Benchmark summary
Structured request workload versus
andrea.marziali/kotlinThe clearest result is about 41% less agent-added allocation, or about 2.7 KiB saved per request. That is a 3.4% reduction in total application allocation in this workload. Total CPU medians are 2–6% lower, but the CPU-focused runs overlap, so these measurements do not establish a reliable CPU speedup.
Both implementations were built on the same upstream base (
d95a6869d8): this branch atf2d1bfd9d0, and Andrea's changes at2c6da9faadapplied to that base. The no-agent baseline executes the same workload with noop tracing calls.CPU overhead and extra allocation are relative to the no-agent baseline. CPU is whole-process CPU time, including agent and GC threads; it is not wall-clock latency. Allocation is whole-process allocated bytes, not retained heap size.
asyncchildren; each child parses data and suspends twice. The supplied builder arguments do not explicitly reusecoroutineContext.delay(1); the CPU-focused variant usesyield(). There is no HTTP framework or real backend I/O. Trace sampling is 100%, and both agents point at an unavailable local collector. Profiling, telemetry, remote configuration, JMX collection and health metrics are disabled.Earlier JMH and coroutine-handoff measurements
Earlier JMH and coroutine-handoff measurements
All measured locally (Apple M1 Max), JMH with
-prof gcplus an end-to-end Kotlin harness under-javaagent. JDK 25, this branch's tip against master at its base (d95a6869d8), unless stated.Code outside coroutines (18 JMH benchmarks: scope lifecycle, context manager, depth, virtual threads; 3 forks x 2 rounds)
SharedScopeStackloaded but unused vs not loadedsharedcheck) puts the only real cost onactivateAndClose. It's about 3.7% slower, roughly 0.45 ns per activate + close, from thesharedflag check on the plain close path.attachAndCloseandactivateSameSpanare within fork spread.ScopeStackcalls inline directly while the subclass isn't loaded, and behind a type-profile check once it's loaded.Shared stack primitives (ns/op)
active()/Context.current()End to end (20k coroutines per round, 15 + 15 rounds, K suspensions each, 3 reps; kotlinx 1.3 and 1.8; fixed pool and
Dispatchers.Default; 12 cells per launch style)launchlaunch(parentContext), root span opened outsideThe branch is fastest where suspensions are frequent: up to 1.15x on the fixed pool at K=16. The two cells where it uses slightly more bytes are K=1 on the fixed pool with parent-context launches (+50–90 B); there, master shares one element across all its coroutines.
From earlier runs on this branch (before the final scope-lifetime changes)
launch(parentContext)onDispatchers.Defaultat K=1, in short runs. About half of it is biased-lock revocation on the new per-coroutine stacks; the rest is JIT warm-up.Dispatchers.Defaultwith one suspension), measured against the branch without it.Additional Notes
Other approaches we tried or considered
Copying the stack at suspension (
mcculls/kotlin-investigation)withContextblock;Reading the previous worker's stack directly (
mcculls/kotlin-live-context-stack,mcculls/kotlin-live-context-per-block)Snapshots published before suspension (
andrea.marziali/kotlin)How: advice on several kotlinx suspension classes publishes a snapshot of the stack through exchange objects. Every suspension does two stack-copying swaps.
Pros: fixes plain launches (0 lost spans); doesn't touch the core scope manager.
Cons:
launch(parentContext), so it loses spans almost as often as master there (up to 9.5M);Measured against an earlier tip of this branch (geomean throughput, this branch vs
andrea.marziali/kotlin):launch(parentContext)Inside this design
ScopeStack, instead of a subclasssynchronized, reads includedactiveSpan()andContext.current()got noticeably slower. A volatile top gives lock-free reads that are still safe.launch(coroutineContext)sharedflag fits in padding and only shared scopes lock.activateAndClosein JMH. No measurable end-to-end difference: an app would need tens of millions of activate/close pairs per second per core to see 1% CPU. The separate flag keeps the counting code easier to read.alive()Known behaviour
ThreadContextElements that open and close Datadog scopes themselves (e.g. OTel's element via our shim), worker B can briefly see A's element scope until A closes it. A's element may also record "scope not on top at restore", which only increments a health metric. The coroutine's own context is never lost. Removing this entirely would mean making B wait for A.@Trace. Using@Tracein Kotlin can produce duplicate spans. That's a separate, pre-existing issue, not addressed here.Contributor Checklist
type:and (comp:orinst:) labels in addition to any other useful labelsclose,fix, or any linking keywords when referencing an issueUse
solvesinstead, and assign the PR milestone to the issue🤖 Generated with Claude Code