Skip to content

Commit 4a2c8ea

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 b3123ec commit 4a2c8ea

3 files changed

Lines changed: 130 additions & 17 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
* [BUGFIX] promhttp: `InstrumentHandlerDuration` and `InstrumentHandlerCounter` no longer panic when given an observer/counter that does not implement `ExemplarObserver`/`ExemplarAdder` (e.g. a `SummaryVec`). The exemplar is dropped and the value is recorded via the plain `Observe`/`Add` path, matching the safe-cast already used by `Timer.ObserveDurationWithExemplar`. #2005
56

67
## Unreleased `exp` module

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

0 commit comments

Comments
 (0)