Skip to content

Commit 2891919

Browse files
xbglowxclaude
andauthored
fix: prevent goroutine leak in downstreamer on context cancellation (#21062)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7155bd5 commit 2891919

2 files changed

Lines changed: 77 additions & 1 deletion

File tree

‎pkg/querier/queryrange/downstreamer.go‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,16 @@ func (in instance) For(
195195
return nil
196196
})
197197
if err != nil {
198-
ch <- logql.Resp{
198+
// Send the error unless the context has been canceled and the
199+
// reader has already exited. Without this select, a canceled
200+
// context causes this goroutine to block on the channel send
201+
// forever, leaking the goroutine.
202+
select {
203+
case ch <- logql.Resp{
199204
I: -1,
200205
Err: err,
206+
}:
207+
case <-ctx.Done():
201208
}
202209
}
203210
close(ch)

‎pkg/querier/queryrange/downstreamer_test.go‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"runtime"
78
"strconv"
89
"strings"
910
"sync"
@@ -587,6 +588,74 @@ func TestCancelWhileWaitingResponse(t *testing.T) {
587588
"but the For method did not return as expected.")
588589
}
589590

591+
func TestCancelDoesNotLeakGoroutines(t *testing.T) {
592+
mkIn := func() *instance {
593+
return DownstreamHandler{
594+
limits: fakeLimits{},
595+
next: nil,
596+
}.Downstreamer(context.Background()).(*instance)
597+
}
598+
in := mkIn()
599+
600+
params, err := logql.NewLiteralParams(
601+
`{app="foo"}`,
602+
time.Now(),
603+
time.Now(),
604+
0,
605+
0,
606+
logproto.BACKWARD,
607+
1000,
608+
nil,
609+
nil,
610+
)
611+
require.NoError(t, err)
612+
613+
queries := make([]logql.DownstreamQuery, in.parallelism+1)
614+
for i := range queries {
615+
queries[i] = logql.DownstreamQuery{Params: params}
616+
}
617+
618+
// Record baseline goroutine count.
619+
runtime.GC()
620+
time.Sleep(50 * time.Millisecond)
621+
baseline := runtime.NumGoroutine()
622+
623+
// Run multiple iterations of canceled queries to amplify any leak.
624+
for i := 0; i < 100; i++ {
625+
ctx, cancel := context.WithCancel(context.Background())
626+
acc := logql.NewBufferedAccumulator(len(queries))
627+
628+
started := atomic.NewInt32(0)
629+
done := make(chan struct{})
630+
go func() {
631+
_, _ = in.For(ctx, queries, acc, func(_ logql.DownstreamQuery) (logqlmodel.Result, error) {
632+
started.Inc()
633+
// Block until context is canceled.
634+
<-ctx.Done()
635+
return logqlmodel.Result{}, ctx.Err()
636+
})
637+
close(done)
638+
}()
639+
640+
// Wait for at least one job to start, then cancel.
641+
require.Eventually(t, func() bool {
642+
return started.Load() > 0
643+
}, 5*time.Second, time.Millisecond)
644+
645+
cancel()
646+
<-done
647+
}
648+
649+
// Allow goroutines to settle.
650+
runtime.GC()
651+
require.Eventually(t, func() bool {
652+
return runtime.NumGoroutine() <= baseline+10
653+
}, 10*time.Second, 100*time.Millisecond,
654+
"goroutine count did not return to baseline; likely leak in For on context cancellation (baseline=%d, current=%d)",
655+
baseline, runtime.NumGoroutine(),
656+
)
657+
}
658+
590659
func TestDownstreamerUsesCorrectParallelism(t *testing.T) {
591660
ctx := user.InjectOrgID(context.Background(), "fake")
592661
l := fakeLimits{maxQueryParallelism: 4}

0 commit comments

Comments
 (0)