Skip to content

Commit b3123ec

Browse files
committed
feat(promhttp): add CoalesceGather option to deduplicate concurrent Gather calls
When a collector's Collect() is slower than the scrape interval, each incoming HTTP request triggers an independent Gather() that spawns its own goroutine pipeline. With no upper bound, this causes goroutine pile-up proportional to (scrape rate / collection time) — the apparent "goroutine leak" reported in #1477. Add HandlerOpts.CoalesceGather bool. When true, HandlerForTransactional wraps the underlying TransactionalGatherer in a coalescingGatherer that allows only one Gather to run at a time. Concurrent requests join the in-flight cycle and receive the same result once it completes. Reference counting ensures the TransactionalGatherer done() callback is called exactly once, after the last handler finishes encoding. Design decisions: - Per-cycle gatherCycle object (not fields on the struct) prevents the race where a new cycle overwrites result fields while prior waiters are still reading them. - Mutex-based ref counting (not atomic) ensures c.cycle = nil is cleared before cy.done() is called, ruling out double-done on a stale pointer. - close(cy.ready) happens-before <-cy.ready returns (Go memory model), so cy.mfs/err/done are safely readable without additional locking. - Zero overhead when disabled: single if-branch in HandlerForTransactional setup, identical code path when CoalesceGather is false. Fixes #1477 Signed-off-by: Kemal Akkoyun <kemal.akkoyun@datadoghq.com>
1 parent e0b7a44 commit b3123ec

2 files changed

Lines changed: 262 additions & 2 deletions

File tree

prometheus/promhttp/http.go

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
"sync"
4242
"time"
4343

44+
dto "github.com/prometheus/client_model/go"
4445
"github.com/prometheus/common/expfmt"
4546

4647
"github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil"
@@ -79,6 +80,88 @@ var gzipPool = sync.Pool{
7980
},
8081
}
8182

83+
// coalescingGatherer wraps a TransactionalGatherer to deduplicate concurrent
84+
// Gather calls. When a Gather is already in flight, new callers join the
85+
// existing cycle and receive the same result once it completes. The underlying
86+
// done function is called exactly once, when the last joined caller releases.
87+
//
88+
// This prevents goroutine pile-up when the scrape rate is faster than the
89+
// time collectors need to produce metrics.
90+
type coalescingGatherer struct {
91+
g prometheus.TransactionalGatherer
92+
mu sync.Mutex
93+
cycle *gatherCycle
94+
}
95+
96+
// gatherCycle tracks a single in-flight Gather and all HTTP handlers sharing it.
97+
type gatherCycle struct {
98+
ready chan struct{} // closed when Gather completes; happens-before reads of mfs/err/done
99+
mfs []*dto.MetricFamily // set before ready is closed; shared across handlers, must not be mutated
100+
err error // set before ready is closed
101+
done func() // underlying done callback; set before ready is closed
102+
refs int // number of handlers using this cycle; protected by coalescingGatherer.mu
103+
}
104+
105+
var _ prometheus.TransactionalGatherer = (*coalescingGatherer)(nil) // compile-time interface check
106+
107+
func (c *coalescingGatherer) Gather() ([]*dto.MetricFamily, func(), error) {
108+
c.mu.Lock()
109+
if cy := c.cycle; cy != nil {
110+
// c.cycle is non-nil while Gather runs or handlers are still consuming its results.
111+
cy.refs++
112+
c.mu.Unlock()
113+
<-cy.ready
114+
return cy.mfs, c.releaseFunc(cy), cy.err
115+
}
116+
cy := &gatherCycle{
117+
ready: make(chan struct{}),
118+
done: func() {},
119+
refs: 1,
120+
}
121+
c.cycle = cy
122+
c.mu.Unlock()
123+
124+
// Guard against a panic in c.g.Gather: close cy.ready and clear c.cycle
125+
// so that any joiners waiting on <-cy.ready are unblocked rather than
126+
// deadlocked, and future Gather calls start a fresh cycle.
127+
panicked := true
128+
defer func() {
129+
if panicked {
130+
c.mu.Lock()
131+
if c.cycle == cy {
132+
c.cycle = nil
133+
}
134+
c.mu.Unlock()
135+
close(cy.ready)
136+
}
137+
}()
138+
cy.mfs, cy.done, cy.err = c.g.Gather()
139+
panicked = false
140+
close(cy.ready) // happens-before joiners' reads of cy.mfs/err/done
141+
142+
return cy.mfs, c.releaseFunc(cy), cy.err
143+
}
144+
145+
// releaseFunc returns the done callback for one caller sharing cy.
146+
// When the last caller releases, the underlying done is invoked and the
147+
// cycle is cleared so the next Gather starts fresh.
148+
func (c *coalescingGatherer) releaseFunc(cy *gatherCycle) func() {
149+
return func() {
150+
c.mu.Lock()
151+
cy.refs--
152+
if cy.refs > 0 {
153+
c.mu.Unlock()
154+
return
155+
}
156+
// Last caller.
157+
if c.cycle == cy {
158+
c.cycle = nil
159+
}
160+
c.mu.Unlock()
161+
cy.done() // called outside the lock to avoid holding it during done
162+
}
163+
}
164+
82165
// Handler returns an http.Handler for the prometheus.DefaultGatherer, using
83166
// default HandlerOpts, i.e. it reports the first error as an HTTP error, it has
84167
// no error logging, and it applies compression if requested by the client.
@@ -125,6 +208,10 @@ func HandlerFor(reg prometheus.Gatherer, opts HandlerOpts) http.Handler {
125208
// Multiple metric names can be specified by providing the parameter multiple times.
126209
// When no name[] parameters are provided, all metrics are returned.
127210
func HandlerForTransactional(reg prometheus.TransactionalGatherer, opts HandlerOpts) http.Handler {
211+
if opts.CoalesceGather {
212+
reg = &coalescingGatherer{g: reg}
213+
}
214+
128215
var (
129216
inFlightSem chan struct{}
130217
errCnt = prometheus.NewCounterVec(
@@ -428,15 +515,32 @@ type HandlerOpts struct {
428515
// Service Unavailable and a suitable message in the body. If
429516
// MaxRequestsInFlight is 0 or negative, no limit is applied.
430517
MaxRequestsInFlight int
518+
// CoalesceGather, if true, deduplicates concurrent Gather calls so that
519+
// only one collection runs at a time. Additional requests that arrive
520+
// while a Gather is in flight will receive the same result once it
521+
// completes. This prevents goroutine pile-up when the scrape rate is
522+
// faster than the time collectors need to produce metrics.
523+
//
524+
// When enabled, concurrent scrapers share a single metric snapshot per
525+
// collection cycle. The returned MetricFamily values are shared and must
526+
// not be mutated in place. The built-in handler only reads them, so this
527+
// is safe in practice, but custom TransactionalGatherer implementations
528+
// that modify the returned families after Gather returns must not use
529+
// this option.
530+
//
531+
// Consider using CoalesceGather together with Timeout to bound both the
532+
// scrape response time and the number of concurrent background Gathers.
533+
CoalesceGather bool
431534
// If handling a request takes longer than Timeout, it is responded to
432535
// with 503 ServiceUnavailable and a suitable Message. No timeout is
433536
// applied if Timeout is 0 or negative. Note that with the current
434537
// implementation, reaching the timeout simply ends the HTTP requests as
435538
// described above (and even that only if sending of the body hasn't
436539
// started yet), while the bulk work of gathering all the metrics keeps
437540
// running in the background (with the eventual result to be thrown
438-
// away). Until the implementation is improved, it is recommended to
439-
// implement a separate timeout in potentially slow Collectors.
541+
// away). When CoalesceGather is enabled, only one such background Gather
542+
// can be in flight at a time. It is also recommended to implement a
543+
// separate timeout in potentially slow Collectors.
440544
Timeout time.Duration
441545
// If true, the experimental OpenMetrics encoding is added to the
442546
// possible options during content negotiation. Note that Prometheus

prometheus/promhttp/http_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ import (
2222
"log"
2323
"net/http"
2424
"net/http/httptest"
25+
"runtime"
2526
"strings"
27+
"sync"
28+
"sync/atomic"
2629
"testing"
2730
"time"
2831

2932
"github.com/klauspost/compress/zstd"
3033
dto "github.com/prometheus/client_model/go"
34+
"go.uber.org/goleak"
3135

3236
"github.com/prometheus/client_golang/prometheus"
3337
_ "github.com/prometheus/client_golang/prometheus/promhttp/zstd"
@@ -875,3 +879,155 @@ func TestHandlerWithMetricFilter(t *testing.T) {
875879
})
876880
}
877881
}
882+
883+
// syncGatherCounter is a thread-safe TransactionalGatherer wrapper that counts
884+
// Gather and done invocations. Safe for concurrent use from multiple goroutines,
885+
// unlike mockTransactionGatherer whose counters are not race-safe.
886+
type syncGatherCounter struct {
887+
g prometheus.Gatherer
888+
gatherCalled atomic.Int64
889+
doneCalled atomic.Int64
890+
}
891+
892+
func (m *syncGatherCounter) Gather() ([]*dto.MetricFamily, func(), error) {
893+
m.gatherCalled.Add(1)
894+
mfs, err := m.g.Gather()
895+
return mfs, func() { m.doneCalled.Add(1) }, err
896+
}
897+
898+
// TestCoalesceGatherSequentialInvariant verifies that sequential requests each
899+
// trigger exactly one Gather call and one done call.
900+
func TestCoalesceGatherSequentialInvariant(t *testing.T) {
901+
reg := prometheus.NewRegistry()
902+
counter := &syncGatherCounter{g: reg}
903+
handler := HandlerForTransactional(counter, HandlerOpts{CoalesceGather: true})
904+
req, _ := http.NewRequest(http.MethodGet, "/", nil)
905+
req.Header.Add(acceptHeader, acceptTextPlain)
906+
907+
const n = 3
908+
for i := range n {
909+
w := httptest.NewRecorder()
910+
handler.ServeHTTP(w, req)
911+
if got, want := w.Code, http.StatusOK; got != want {
912+
t.Fatalf("request %d: HTTP status %d, want %d", i+1, got, want)
913+
}
914+
}
915+
if got, want := counter.gatherCalled.Load(), int64(n); got != want {
916+
t.Errorf("Gather called %d times, want %d", got, want)
917+
}
918+
if got, want := counter.doneCalled.Load(), int64(n); got != want {
919+
t.Errorf("done called %d times, want %d", got, want)
920+
}
921+
}
922+
923+
// TestCoalesceGatherDoneCalledExactlyOnce verifies that when concurrent requests
924+
// share a single Gather cycle, the underlying done callback is called exactly once.
925+
func TestCoalesceGatherDoneCalledExactlyOnce(t *testing.T) {
926+
defer goleak.VerifyNone(t)
927+
928+
reg := prometheus.NewRegistry()
929+
block := make(chan struct{})
930+
started := make(chan struct{}, 1)
931+
reg.MustRegister(blockingCollector{CollectStarted: started, Block: block})
932+
933+
counter := &syncGatherCounter{g: reg}
934+
handler := HandlerForTransactional(counter, HandlerOpts{CoalesceGather: true})
935+
req, _ := http.NewRequest(http.MethodGet, "/", nil)
936+
req.Header.Add(acceptHeader, acceptTextPlain)
937+
938+
// Start request 1 in background; it blocks in Collect.
939+
w1 := httptest.NewRecorder()
940+
req1Done := make(chan struct{})
941+
go func() {
942+
handler.ServeHTTP(w1, req)
943+
close(req1Done)
944+
}()
945+
<-started // Gather 1 is now in-flight and blocked in Collect.
946+
947+
// Start request 2; it will join the in-flight Gather cycle.
948+
w2 := httptest.NewRecorder()
949+
req2Done := make(chan struct{})
950+
go func() {
951+
handler.ServeHTTP(w2, req)
952+
close(req2Done)
953+
}()
954+
955+
// Yield to allow request 2 to enter the coalescing wait before releasing.
956+
runtime.Gosched()
957+
close(block)
958+
<-req1Done
959+
<-req2Done
960+
961+
// Key invariant: done() must be called exactly once per Gather cycle.
962+
gathers := counter.gatherCalled.Load()
963+
dones := counter.doneCalled.Load()
964+
if gathers != dones {
965+
t.Errorf("Gather called %d times but done called %d times; invariant violated", gathers, dones)
966+
}
967+
// Coalescing should keep gather count below the number of requests.
968+
if gathers > 2 {
969+
t.Errorf("Gather called %d times for 2 requests; expected ≤ 2 with coalescing", gathers)
970+
}
971+
for i, w := range []*httptest.ResponseRecorder{w1, w2} {
972+
if got, want := w.Code, http.StatusOK; got != want {
973+
t.Errorf("request %d: HTTP status %d, want %d", i+1, got, want)
974+
}
975+
}
976+
}
977+
978+
// TestCoalesceGatherGoroutineLeakFree verifies that concurrent requests with a
979+
// slow collector do not leak goroutines when CoalesceGather is enabled.
980+
func TestCoalesceGatherGoroutineLeakFree(t *testing.T) {
981+
defer goleak.VerifyNone(t)
982+
983+
reg := prometheus.NewRegistry()
984+
block := make(chan struct{})
985+
started := make(chan struct{}, 1)
986+
reg.MustRegister(blockingCollector{CollectStarted: started, Block: block})
987+
988+
handler := HandlerForTransactional(
989+
&syncGatherCounter{g: reg},
990+
HandlerOpts{CoalesceGather: true},
991+
)
992+
req, _ := http.NewRequest(http.MethodGet, "/", nil)
993+
req.Header.Add(acceptHeader, acceptTextPlain)
994+
995+
var wg sync.WaitGroup
996+
for range 5 {
997+
wg.Go(func() {
998+
handler.ServeHTTP(httptest.NewRecorder(), req)
999+
})
1000+
}
1001+
<-started
1002+
close(block)
1003+
wg.Wait()
1004+
// goleak.VerifyNone (deferred above) asserts no goroutines leaked.
1005+
}
1006+
1007+
// TestCoalesceGatherNewCycleAfterCompletion verifies that once all handlers of a
1008+
// cycle have released, the next request starts a fresh Gather.
1009+
func TestCoalesceGatherNewCycleAfterCompletion(t *testing.T) {
1010+
reg := prometheus.NewRegistry()
1011+
counter := &syncGatherCounter{g: reg}
1012+
handler := HandlerForTransactional(counter, HandlerOpts{CoalesceGather: true})
1013+
req, _ := http.NewRequest(http.MethodGet, "/", nil)
1014+
req.Header.Add(acceptHeader, acceptTextPlain)
1015+
1016+
// Cycle 1.
1017+
handler.ServeHTTP(httptest.NewRecorder(), req)
1018+
if got, want := counter.gatherCalled.Load(), int64(1); got != want {
1019+
t.Fatalf("after cycle 1: Gather called %d times, want %d", got, want)
1020+
}
1021+
if got, want := counter.doneCalled.Load(), int64(1); got != want {
1022+
t.Fatalf("after cycle 1: done called %d times, want %d", got, want)
1023+
}
1024+
1025+
// Cycle 2: previous cycle is complete so a fresh Gather must run.
1026+
handler.ServeHTTP(httptest.NewRecorder(), req)
1027+
if got, want := counter.gatherCalled.Load(), int64(2); got != want {
1028+
t.Errorf("after cycle 2: Gather called %d times, want %d", got, want)
1029+
}
1030+
if got, want := counter.doneCalled.Load(), int64(2); got != want {
1031+
t.Errorf("after cycle 2: done called %d times, want %d", got, want)
1032+
}
1033+
}

0 commit comments

Comments
 (0)