Skip to content

Commit b8baea9

Browse files
authored
fix(orchestrion): allow the experimental span pool together with orchestrion GLS
Final PR in the GLS span-pool stack. With the reclaim signal decoupled from the recyclable span (cell-based, captured at push time) and Peek skipping finished entries, the two reasons #4891 gated span pool and orchestrion GLS as mutually exclusive no longer hold: a recycled span can neither leak a stale GLS entry nor resurface as the active span. Remove the gate (the shouldDisableSpanPool helper, its newConfig call, and its unit test) so WithSpanPool(true) / DD_TRACER_EXPERIMENTAL_SPAN_POOL_ENABLED is honored under orchestrion. Add TestGLSNoHeapLeakWithSpanPool as the coexistence regression gate. It enables the pool via WithSpanPool(true) and runs glsleak.MeasureLeakLiveInject: the realistic franz-go shape that injects each span while live and finishes it after the worker pushes it, respecting the span pool's "do not touch a span after Finish" contract. Run under -race in CI it guards both the leak and the span-pool-vs-GLS data races. The existing finish-then-inject TestGLSNoHeapLeak stays as the no-pool gate (its finish-before-inject order is a deliberate use-after-Finish that the pool legitimately recycles, so it is not run pooled). Refs: DataDog/orchestrion#782 Environment: Datadog workspace Co-authored-by: Kemal Akkoyun <kakkoyun@users.noreply.github.com>
1 parent c24407c commit b8baea9

5 files changed

Lines changed: 101 additions & 60 deletions

File tree

ddtrace/tracer/option.go

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -234,18 +234,6 @@ func newConfig(opts ...StartOption) (*config, error) {
234234
}
235235
fn(c)
236236
}
237-
// The experimental span pool and Orchestrion's GLS weave are mutually
238-
// exclusive. Span pooling recycles a finished span via sync.Pool; under
239-
// Orchestrion that span may still be referenced from a goroutine-local
240-
// storage (GLS) stack whose stale entry has not been drained, so reusing it
241-
// can resurface a recycled span or leak the entry (see orchestrion#782).
242-
// Until the reclaim signal is decoupled from the pooled span, disable
243-
// pooling when Orchestrion is active and warn once. Checked after the option
244-
// loop so an explicit WithSpanPool(true) is gated too.
245-
if shouldDisableSpanPool(c.spanPoolEnabled, orchestrion.Enabled()) {
246-
c.spanPoolEnabled = false
247-
log.Warn("the experimental span pool (DD_TRACER_EXPERIMENTAL_SPAN_POOL_ENABLED / WithSpanPool) is incompatible with Orchestrion and has been disabled")
248-
}
249237
rawAgentURL := c.internalConfig.RawAgentURL()
250238
if c.httpClient == nil || orchestrion.Enabled() {
251239
if orchestrion.Enabled() && c.httpClient != nil {
@@ -377,16 +365,6 @@ func newConfig(opts ...StartOption) (*config, error) {
377365
return c, nil
378366
}
379367

380-
// shouldDisableSpanPool reports whether the experimental span pool must be
381-
// turned off because Orchestrion's GLS weave is active. The two are mutually
382-
// exclusive: pooling can recycle a finished span whose stale GLS entry has not
383-
// yet been drained, which the GLS reclaim path does not yet tolerate
384-
// (orchestrion#782). It is a pure helper so the gate is unit-testable without an
385-
// Orchestrion build (orchestrion.Enabled() is a build-time constant).
386-
func shouldDisableSpanPool(spanPoolEnabled, orchestrionEnabled bool) bool {
387-
return spanPoolEnabled && orchestrionEnabled
388-
}
389-
390368
func llmobsAgentlessEnabledFromEnv() *bool {
391369
v, ok := internal.BoolEnvNoDefault(envLLMObsAgentlessEnabled)
392370
if !ok {

ddtrace/tracer/span_pool_gate_test.go

Lines changed: 0 additions & 38 deletions
This file was deleted.

internal/orchestrion/_integration/gls-leak/leak_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,28 @@ func TestGLSNoHeapLeak(t *testing.T) {
5151
"one span per record) — the contextStack.Push reclaim in ddtrace/tracer/orchestrion.yml regressed",
5252
r.PerRecord)
5353
}
54+
55+
// TestGLSNoHeapLeakWithSpanPool is the regression gate for running the
56+
// experimental span pool together with orchestrion GLS (the combination #4891
57+
// gated off and this stack re-enables). It uses the live-inject workload, which
58+
// respects the span pool's "do not use a span after Finish" contract, and
59+
// enables pooling via WithSpanPool(true). With the decoupled, cell-based reclaim
60+
// the worker's GLS stack stays flat even though every finished span is recycled
61+
// by the pool; without it, recycled spans would either leak (stale entries never
62+
// drained) or resurface as the wrong active span. Run under -race in CI, it also
63+
// guards against the span-pool-vs-GLS data races from the review.
64+
func TestGLSNoHeapLeakWithSpanPool(t *testing.T) {
65+
if !orchestrionEnabled {
66+
t.Skip("GLS only exists in orchestrion builds")
67+
}
68+
require.True(t, built.WithOrchestrion)
69+
70+
require.NoError(t, tracer.Start(tracer.WithLogStartup(false), tracer.WithSpanPool(true)))
71+
defer tracer.Stop()
72+
73+
r := glsleak.MeasureLeakLiveInject(200_000)
74+
require.Lessf(t, r.PerRecord, glsleak.MaxRetainedObjectsPerRecord,
75+
"GLS span leak with span pool: %.3f retained heap objects/record (want flat ~0) — "+
76+
"the decoupled reclaim regressed, or span pool + orchestrion GLS no longer coexist safely",
77+
r.PerRecord)
78+
}

internal/orchestrion/_integration/gls/span_gls_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ import (
4242
//
4343
// Without the injected fix this goroutine's GLS grows by one entry per record
4444
// (an unbounded leak proportional to the record count); with it, depth stays ~1.
45+
//
46+
// This test deliberately finishes the span BEFORE re-injecting it, so it is not
47+
// run with the experimental span pool, which legitimately recycles a finished
48+
// span (touching it afterward is out of the pool's contract). The span-pool +
49+
// GLS coexistence is covered by the live-inject TestGLSNoHeapLeakWithSpanPool.
4550
func TestSpanGLSNoLeakCrossGoroutine(t *testing.T) {
4651
if !orchestrionEnabled {
4752
t.Skip("GLS only exists in orchestrion builds")
@@ -96,6 +101,10 @@ func TestSpanGLSNoLeakCrossGoroutine(t *testing.T) {
96101
// additionally catches a regression that still pushes but stops reclaiming in a
97102
// way the bounded-depth check might not. The runnable gls-leak command exercises
98103
// the same helper.
104+
//
105+
// Like TestSpanGLSNoLeakCrossGoroutine, this uses the finish-then-inject order
106+
// and is therefore not run under the experimental span pool (which recycles the
107+
// finished span); TestGLSNoHeapLeakWithSpanPool covers the pooled, live-inject path.
99108
func TestSpanGLSNoHeapLeakCrossGoroutine(t *testing.T) {
100109
if !orchestrionEnabled {
101110
t.Skip("GLS only exists in orchestrion builds")

internal/orchestrion/_integration/internal/glsleak/glsleak.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,70 @@ func MeasureLeak(n int) Result {
9393
PerRecord: float64(objects) / float64(n),
9494
}
9595
}
96+
97+
// MeasureLeakLiveInject runs the same cross-goroutine GLS-leak measurement as
98+
// MeasureLeak, but injects each span while it is still LIVE and finishes it only
99+
// after the worker has pushed it. This matches the real franz-go/Kafka shape
100+
// (a span is put into the context while in flight, then finished) and, unlike
101+
// MeasureLeak's finish-then-inject order, respects the span pool's "do not touch
102+
// a span after Finish" contract. It is therefore the workload used to assert
103+
// that the experimental span pool and orchestrion GLS coexist without leaking
104+
// (orchestrion#782): the reclaim cell is captured at push time while the span is
105+
// live, finish marks it, and the worker's next push drains the stale entry even
106+
// after the span object has been recycled by the pool.
107+
//
108+
// A per-item handshake orders the worker's push ahead of the owner's Finish, so
109+
// the worker never reads a span's fields while the owner finishes (or the pool
110+
// recycles) it: the workload stays data-race-free under -race while still
111+
// exercising pool reuse and cross-goroutine reclaim.
112+
//
113+
// The tracer must already be started by the caller (with the span pool enabled,
114+
// e.g. tracer.WithSpanPool(true)). n <= 0 returns a zero Result.
115+
func MeasureLeakLiveInject(n int) Result {
116+
if n <= 0 {
117+
return Result{Records: n}
118+
}
119+
base := context.Background()
120+
121+
run := func() {
122+
spanCh := make(chan *tracer.Span)
123+
pushedCh := make(chan struct{})
124+
var wg sync.WaitGroup
125+
wg.Go(func() { // worker: pushes the live span onto its own GLS stack
126+
for s := range spanCh {
127+
_ = tracer.ContextWithSpan(base, s)
128+
pushedCh <- struct{}{} // signal: done reading s, owner may finish it
129+
}
130+
})
131+
for range n { // owner: creates and (after the worker pushed) finishes the span
132+
s := tracer.StartSpan("kafka.consume")
133+
spanCh <- s // hand the LIVE span to the worker
134+
<-pushedCh // wait until the worker pushed it (orders push before finish)
135+
s.Finish() // finish on the owner; the worker's popper is a no-op here
136+
}
137+
close(spanCh)
138+
wg.Wait()
139+
}
140+
141+
run() // warm up so first-run/lazy allocations don't count toward the delta
142+
143+
runtime.GC()
144+
var before runtime.MemStats
145+
runtime.ReadMemStats(&before)
146+
147+
run()
148+
149+
tracer.Flush() // drop buffered spans so only a GLS leak can retain them
150+
runtime.GC()
151+
runtime.GC()
152+
var after runtime.MemStats
153+
runtime.ReadMemStats(&after)
154+
155+
objects := int64(after.HeapObjects) - int64(before.HeapObjects)
156+
return Result{
157+
Records: n,
158+
Objects: objects,
159+
Bytes: int64(after.HeapInuse) - int64(before.HeapInuse),
160+
PerRecord: float64(objects) / float64(n),
161+
}
162+
}

0 commit comments

Comments
 (0)