Skip to content

Commit ea472d7

Browse files
committed
fix(promhttp): fail coalesced joiners when the gatherer panics
When CoalesceGather is enabled and the wrapped gatherer itself panics, requests that joined the in-flight cycle were unblocked with a nil error and returned an empty but successful response. They now receive a sentinel error and fail like the panicking request instead of silently reporting no metrics. The leader's panic still propagates and is handled by net/http, matching the non-coalesced path; no recover is introduced. Also document the panic behaviour on the option, make the coalescing invariant test deterministic instead of timing-dependent, and align the goroutine-leak test with the repository's WaitGroup convention. Signed-off-by: Kemal Akkoyun <kemal.akkoyun@datadoghq.com>
1 parent b8d8a07 commit ea472d7

3 files changed

Lines changed: 134 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
## Unreleased
22

33
* [FEATURE] HTTP handlers created by `promhttp` package now support metrics filtering by providing one or more `name[]` query parameters. The default behavior when none are provided remains the same, returning all metrics. #1925
4+
* [FEATURE] promhttp: Add opt-in `HandlerOpts.CoalesceGather` to deduplicate concurrent `Gather` calls so overlapping scrapes share one collection cycle, preventing goroutine pile-up when the scrape rate outpaces collection time. #1969
45

56
## Unreleased `exp` module
67

prometheus/promhttp/http.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ type gatherCycle struct {
104104

105105
var _ prometheus.TransactionalGatherer = (*coalescingGatherer)(nil) // compile-time interface check
106106

107+
// errGatherPanicked is returned to callers that joined an in-flight coalesced
108+
// Gather whose underlying gatherer panicked. See the panic guard in Gather for
109+
// why joiners receive this error instead of the panic itself.
110+
var errGatherPanicked = errors.New("promhttp: coalesced gather panicked")
111+
107112
func (c *coalescingGatherer) Gather() ([]*dto.MetricFamily, func(), error) {
108113
c.mu.Lock()
109114
if cy := c.cycle; cy != nil {
@@ -121,9 +126,16 @@ func (c *coalescingGatherer) Gather() ([]*dto.MetricFamily, func(), error) {
121126
c.cycle = cy
122127
c.mu.Unlock()
123128

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.
129+
// Guard against a panic in c.g.Gather. The common case, a panicking
130+
// Collector, never reaches here: Registry.Gather recovers Collector panics
131+
// and returns them as an error. This guard only covers the rare case where
132+
// the wrapped gatherer itself panics.
133+
//
134+
// We deliberately do not recover: the leader's panic propagates and is
135+
// handled by net/http exactly as it would be without coalescing. We only
136+
// set cy.err before closing cy.ready so joiners waiting on <-cy.ready fail
137+
// with that error instead of silently returning an empty, successful
138+
// response, and we clear c.cycle so the next Gather starts a fresh cycle.
127139
panicked := true
128140
defer func() {
129141
if panicked {
@@ -132,6 +144,7 @@ func (c *coalescingGatherer) Gather() ([]*dto.MetricFamily, func(), error) {
132144
c.cycle = nil
133145
}
134146
c.mu.Unlock()
147+
cy.err = errGatherPanicked // set before close: happens-before joiners' reads
135148
close(cy.ready)
136149
}
137150
}()
@@ -530,6 +543,12 @@ type HandlerOpts struct {
530543
//
531544
// Consider using CoalesceGather together with Timeout to bound both the
532545
// scrape response time and the number of concurrent background Gathers.
546+
//
547+
// Panic handling: a panicking Collector is already turned into an error by
548+
// the registry, so joiners receive that error like any other. In the rare
549+
// case where the wrapped gatherer itself panics, the panicking request's
550+
// panic propagates as usual (handled by net/http), while requests that
551+
// joined the same cycle receive an error rather than an empty response.
533552
CoalesceGather bool
534553
// If handling a request takes longer than Timeout, it is responded to
535554
// with 503 ServiceUnavailable and a suitable Message. No timeout is

prometheus/promhttp/http_test.go

Lines changed: 111 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import (
2222
"log"
2323
"net/http"
2424
"net/http/httptest"
25-
"runtime"
2625
"strings"
2726
"sync"
2827
"sync/atomic"
@@ -920,8 +919,8 @@ func TestCoalesceGatherSequentialInvariant(t *testing.T) {
920919
}
921920
}
922921

923-
// TestCoalesceGatherDoneCalledExactlyOnce verifies that when concurrent requests
924-
// share a single Gather cycle, the underlying done callback is called exactly once.
922+
// TestCoalesceGatherDoneCalledExactlyOnce verifies that when a second request
923+
// joins an in-flight Gather cycle, exactly one Gather and one done call occur.
925924
func TestCoalesceGatherDoneCalledExactlyOnce(t *testing.T) {
926925
defer goleak.VerifyNone(t)
927926

@@ -930,8 +929,12 @@ func TestCoalesceGatherDoneCalledExactlyOnce(t *testing.T) {
930929
started := make(chan struct{}, 1)
931930
reg.MustRegister(blockingCollector{CollectStarted: started, Block: block})
932931

932+
// Wrap explicitly so the test can observe when request 2 has joined the
933+
// in-flight cycle. HandlerForTransactional with CoalesceGather builds an
934+
// equivalent wrapper internally but keeps it hidden from the test.
933935
counter := &syncGatherCounter{g: reg}
934-
handler := HandlerForTransactional(counter, HandlerOpts{CoalesceGather: true})
936+
cg := &coalescingGatherer{g: counter}
937+
handler := HandlerForTransactional(cg, HandlerOpts{})
935938
req, _ := http.NewRequest(http.MethodGet, "/", nil)
936939
req.Header.Add(acceptHeader, acceptTextPlain)
937940

@@ -952,21 +955,19 @@ func TestCoalesceGatherDoneCalledExactlyOnce(t *testing.T) {
952955
close(req2Done)
953956
}()
954957

955-
// Yield to allow request 2 to enter the coalescing wait before releasing.
956-
runtime.Gosched()
958+
// Wait until request 2 has joined the cycle (refs == 2) before releasing,
959+
// so coalescing is guaranteed rather than dependent on goroutine timing.
960+
waitForRefs(t, cg, 2)
957961
close(block)
958962
<-req1Done
959963
<-req2Done
960964

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)
965+
// With request 2 joining an in-flight cycle, both share one Gather and one done.
966+
if got := counter.gatherCalled.Load(); got != 1 {
967+
t.Errorf("Gather called %d times for 2 coalesced requests, want exactly 1", got)
966968
}
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)
969+
if got := counter.doneCalled.Load(); got != 1 {
970+
t.Errorf("done called %d times, want exactly 1", got)
970971
}
971972
for i, w := range []*httptest.ResponseRecorder{w1, w2} {
972973
if got, want := w.Code, http.StatusOK; got != want {
@@ -994,9 +995,11 @@ func TestCoalesceGatherGoroutineLeakFree(t *testing.T) {
994995

995996
var wg sync.WaitGroup
996997
for range 5 {
997-
wg.Go(func() {
998+
wg.Add(1)
999+
go func() {
1000+
defer wg.Done()
9981001
handler.ServeHTTP(httptest.NewRecorder(), req)
999-
})
1002+
}()
10001003
}
10011004
<-started
10021005
close(block)
@@ -1031,3 +1034,95 @@ func TestCoalesceGatherNewCycleAfterCompletion(t *testing.T) {
10311034
t.Errorf("after cycle 2: done called %d times, want %d", got, want)
10321035
}
10331036
}
1037+
1038+
// inflightRefs reports how many callers currently share the in-flight cycle.
1039+
// It lives in the test file so production code carries no test-only hooks; tests
1040+
// use it to wait deterministically until a joiner has entered the cycle.
1041+
func (c *coalescingGatherer) inflightRefs() int {
1042+
c.mu.Lock()
1043+
defer c.mu.Unlock()
1044+
if c.cycle == nil {
1045+
return 0
1046+
}
1047+
return c.cycle.refs
1048+
}
1049+
1050+
// waitForRefs blocks until cg reports at least want in-flight refs, failing the
1051+
// test if that does not happen promptly.
1052+
func waitForRefs(t *testing.T, cg *coalescingGatherer, want int) {
1053+
t.Helper()
1054+
deadline := time.Now().Add(2 * time.Second)
1055+
for cg.inflightRefs() < want {
1056+
if time.Now().After(deadline) {
1057+
t.Fatalf("timed out waiting for %d in-flight refs, have %d", want, cg.inflightRefs())
1058+
}
1059+
time.Sleep(time.Millisecond)
1060+
}
1061+
}
1062+
1063+
// panicGatherer is a TransactionalGatherer whose Gather panics after signaling
1064+
// that it started and waiting to be released. A panicking Collector cannot reach
1065+
// the panic guard in coalescingGatherer because Registry.Gather recovers
1066+
// Collector panics into errors, so the panic is raised at the gatherer level.
1067+
type panicGatherer struct {
1068+
started chan struct{}
1069+
block chan struct{}
1070+
}
1071+
1072+
func (p *panicGatherer) Gather() ([]*dto.MetricFamily, func(), error) {
1073+
close(p.started)
1074+
<-p.block
1075+
panic("boom")
1076+
}
1077+
1078+
// TestCoalesceGatherPanicUnblocksJoinersWithError verifies that when the wrapped
1079+
// gatherer panics, a request that joined the same cycle receives errGatherPanicked
1080+
// instead of a nil error with an empty response, the leader's panic still
1081+
// propagates, and the cycle is cleared afterward.
1082+
func TestCoalesceGatherPanicUnblocksJoinersWithError(t *testing.T) {
1083+
defer goleak.VerifyNone(t)
1084+
1085+
p := &panicGatherer{started: make(chan struct{}), block: make(chan struct{})}
1086+
cg := &coalescingGatherer{g: p}
1087+
1088+
// Leader creates the cycle and panics once released; recover so the test
1089+
// goroutine survives, mirroring net/http's per-request panic recovery.
1090+
leaderPanicked := make(chan any, 1)
1091+
go func() {
1092+
defer func() { leaderPanicked <- recover() }()
1093+
_, done, _ := cg.Gather()
1094+
if done != nil {
1095+
done()
1096+
}
1097+
}()
1098+
<-p.started
1099+
1100+
// Joiner joins the in-flight cycle and blocks on <-cy.ready.
1101+
type joinResult struct {
1102+
done func()
1103+
err error
1104+
}
1105+
joined := make(chan joinResult, 1)
1106+
go func() {
1107+
_, done, err := cg.Gather()
1108+
joined <- joinResult{done: done, err: err}
1109+
}()
1110+
1111+
// Ensure the joiner has joined the cycle before releasing the panic.
1112+
waitForRefs(t, cg, 2)
1113+
close(p.block)
1114+
1115+
if r := <-leaderPanicked; r == nil {
1116+
t.Error("leader Gather did not panic; want the panic to propagate")
1117+
}
1118+
res := <-joined
1119+
if !errors.Is(res.err, errGatherPanicked) {
1120+
t.Errorf("joiner err = %v, want errGatherPanicked", res.err)
1121+
}
1122+
if res.done != nil {
1123+
res.done() // must be safe to call: no panic, no double-done.
1124+
}
1125+
if got := cg.inflightRefs(); got != 0 {
1126+
t.Errorf("cycle not cleared after panic: inflightRefs = %d, want 0", got)
1127+
}
1128+
}

0 commit comments

Comments
 (0)