Skip to content

Commit 81c415a

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 8f78afd commit 81c415a

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(
@@ -426,15 +513,32 @@ type HandlerOpts struct {
426513
// Service Unavailable and a suitable message in the body. If
427514
// MaxRequestsInFlight is 0 or negative, no limit is applied.
428515
MaxRequestsInFlight int
516+
// CoalesceGather, if true, deduplicates concurrent Gather calls so that
517+
// only one collection runs at a time. Additional requests that arrive
518+
// while a Gather is in flight will receive the same result once it
519+
// completes. This prevents goroutine pile-up when the scrape rate is
520+
// faster than the time collectors need to produce metrics.
521+
//
522+
// When enabled, concurrent scrapers share a single metric snapshot per
523+
// collection cycle. The returned MetricFamily values are shared and must
524+
// not be mutated in place. The built-in handler only reads them, so this
525+
// is safe in practice, but custom TransactionalGatherer implementations
526+
// that modify the returned families after Gather returns must not use
527+
// this option.
528+
//
529+
// Consider using CoalesceGather together with Timeout to bound both the
530+
// scrape response time and the number of concurrent background Gathers.
531+
CoalesceGather bool
429532
// If handling a request takes longer than Timeout, it is responded to
430533
// with 503 ServiceUnavailable and a suitable Message. No timeout is
431534
// applied if Timeout is 0 or negative. Note that with the current
432535
// implementation, reaching the timeout simply ends the HTTP requests as
433536
// described above (and even that only if sending of the body hasn't
434537
// started yet), while the bulk work of gathering all the metrics keeps
435538
// running in the background (with the eventual result to be thrown
436-
// away). Until the implementation is improved, it is recommended to
437-
// implement a separate timeout in potentially slow Collectors.
539+
// away). When CoalesceGather is enabled, only one such background Gather
540+
// can be in flight at a time. It is also recommended to implement a
541+
// separate timeout in potentially slow Collectors.
438542
Timeout time.Duration
439543
// If true, the experimental OpenMetrics encoding is added to the
440544
// 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"
@@ -747,3 +751,155 @@ func TestHandlerWithMetricFilter(t *testing.T) {
747751
})
748752
}
749753
}
754+
755+
// syncGatherCounter is a thread-safe TransactionalGatherer wrapper that counts
756+
// Gather and done invocations. Safe for concurrent use from multiple goroutines,
757+
// unlike mockTransactionGatherer whose counters are not race-safe.
758+
type syncGatherCounter struct {
759+
g prometheus.Gatherer
760+
gatherCalled atomic.Int64
761+
doneCalled atomic.Int64
762+
}
763+
764+
func (m *syncGatherCounter) Gather() ([]*dto.MetricFamily, func(), error) {
765+
m.gatherCalled.Add(1)
766+
mfs, err := m.g.Gather()
767+
return mfs, func() { m.doneCalled.Add(1) }, err
768+
}
769+
770+
// TestCoalesceGatherSequentialInvariant verifies that sequential requests each
771+
// trigger exactly one Gather call and one done call.
772+
func TestCoalesceGatherSequentialInvariant(t *testing.T) {
773+
reg := prometheus.NewRegistry()
774+
counter := &syncGatherCounter{g: reg}
775+
handler := HandlerForTransactional(counter, HandlerOpts{CoalesceGather: true})
776+
req, _ := http.NewRequest(http.MethodGet, "/", nil)
777+
req.Header.Add(acceptHeader, acceptTextPlain)
778+
779+
const n = 3
780+
for i := range n {
781+
w := httptest.NewRecorder()
782+
handler.ServeHTTP(w, req)
783+
if got, want := w.Code, http.StatusOK; got != want {
784+
t.Fatalf("request %d: HTTP status %d, want %d", i+1, got, want)
785+
}
786+
}
787+
if got, want := counter.gatherCalled.Load(), int64(n); got != want {
788+
t.Errorf("Gather called %d times, want %d", got, want)
789+
}
790+
if got, want := counter.doneCalled.Load(), int64(n); got != want {
791+
t.Errorf("done called %d times, want %d", got, want)
792+
}
793+
}
794+
795+
// TestCoalesceGatherDoneCalledExactlyOnce verifies that when concurrent requests
796+
// share a single Gather cycle, the underlying done callback is called exactly once.
797+
func TestCoalesceGatherDoneCalledExactlyOnce(t *testing.T) {
798+
defer goleak.VerifyNone(t)
799+
800+
reg := prometheus.NewRegistry()
801+
block := make(chan struct{})
802+
started := make(chan struct{}, 1)
803+
reg.MustRegister(blockingCollector{CollectStarted: started, Block: block})
804+
805+
counter := &syncGatherCounter{g: reg}
806+
handler := HandlerForTransactional(counter, HandlerOpts{CoalesceGather: true})
807+
req, _ := http.NewRequest(http.MethodGet, "/", nil)
808+
req.Header.Add(acceptHeader, acceptTextPlain)
809+
810+
// Start request 1 in background; it blocks in Collect.
811+
w1 := httptest.NewRecorder()
812+
req1Done := make(chan struct{})
813+
go func() {
814+
handler.ServeHTTP(w1, req)
815+
close(req1Done)
816+
}()
817+
<-started // Gather 1 is now in-flight and blocked in Collect.
818+
819+
// Start request 2; it will join the in-flight Gather cycle.
820+
w2 := httptest.NewRecorder()
821+
req2Done := make(chan struct{})
822+
go func() {
823+
handler.ServeHTTP(w2, req)
824+
close(req2Done)
825+
}()
826+
827+
// Yield to allow request 2 to enter the coalescing wait before releasing.
828+
runtime.Gosched()
829+
close(block)
830+
<-req1Done
831+
<-req2Done
832+
833+
// Key invariant: done() must be called exactly once per Gather cycle.
834+
gathers := counter.gatherCalled.Load()
835+
dones := counter.doneCalled.Load()
836+
if gathers != dones {
837+
t.Errorf("Gather called %d times but done called %d times; invariant violated", gathers, dones)
838+
}
839+
// Coalescing should keep gather count below the number of requests.
840+
if gathers > 2 {
841+
t.Errorf("Gather called %d times for 2 requests; expected ≤ 2 with coalescing", gathers)
842+
}
843+
for i, w := range []*httptest.ResponseRecorder{w1, w2} {
844+
if got, want := w.Code, http.StatusOK; got != want {
845+
t.Errorf("request %d: HTTP status %d, want %d", i+1, got, want)
846+
}
847+
}
848+
}
849+
850+
// TestCoalesceGatherGoroutineLeakFree verifies that concurrent requests with a
851+
// slow collector do not leak goroutines when CoalesceGather is enabled.
852+
func TestCoalesceGatherGoroutineLeakFree(t *testing.T) {
853+
defer goleak.VerifyNone(t)
854+
855+
reg := prometheus.NewRegistry()
856+
block := make(chan struct{})
857+
started := make(chan struct{}, 1)
858+
reg.MustRegister(blockingCollector{CollectStarted: started, Block: block})
859+
860+
handler := HandlerForTransactional(
861+
&syncGatherCounter{g: reg},
862+
HandlerOpts{CoalesceGather: true},
863+
)
864+
req, _ := http.NewRequest(http.MethodGet, "/", nil)
865+
req.Header.Add(acceptHeader, acceptTextPlain)
866+
867+
var wg sync.WaitGroup
868+
for range 5 {
869+
wg.Go(func() {
870+
handler.ServeHTTP(httptest.NewRecorder(), req)
871+
})
872+
}
873+
<-started
874+
close(block)
875+
wg.Wait()
876+
// goleak.VerifyNone (deferred above) asserts no goroutines leaked.
877+
}
878+
879+
// TestCoalesceGatherNewCycleAfterCompletion verifies that once all handlers of a
880+
// cycle have released, the next request starts a fresh Gather.
881+
func TestCoalesceGatherNewCycleAfterCompletion(t *testing.T) {
882+
reg := prometheus.NewRegistry()
883+
counter := &syncGatherCounter{g: reg}
884+
handler := HandlerForTransactional(counter, HandlerOpts{CoalesceGather: true})
885+
req, _ := http.NewRequest(http.MethodGet, "/", nil)
886+
req.Header.Add(acceptHeader, acceptTextPlain)
887+
888+
// Cycle 1.
889+
handler.ServeHTTP(httptest.NewRecorder(), req)
890+
if got, want := counter.gatherCalled.Load(), int64(1); got != want {
891+
t.Fatalf("after cycle 1: Gather called %d times, want %d", got, want)
892+
}
893+
if got, want := counter.doneCalled.Load(), int64(1); got != want {
894+
t.Fatalf("after cycle 1: done called %d times, want %d", got, want)
895+
}
896+
897+
// Cycle 2: previous cycle is complete so a fresh Gather must run.
898+
handler.ServeHTTP(httptest.NewRecorder(), req)
899+
if got, want := counter.gatherCalled.Load(), int64(2); got != want {
900+
t.Errorf("after cycle 2: Gather called %d times, want %d", got, want)
901+
}
902+
if got, want := counter.doneCalled.Load(), int64(2); got != want {
903+
t.Errorf("after cycle 2: done called %d times, want %d", got, want)
904+
}
905+
}

0 commit comments

Comments
 (0)