Skip to content

Commit b137906

Browse files
committed
fix(promhttp): give coalesced callers independent metric slices
Concurrent callers sharing a coalesced Gather cycle previously received the same slice header. A caller that filtered or reordered its slice (for example applying name[] query parameters) could then race another caller of the same cycle. Each caller now gets its own slices.Clone; the MetricFamily values stay shared and read-only. Also clarify the CoalesceGather documentation (shared-snapshot staleness, the Timeout interaction, and slice-versus-contents mutation) and tidy the sentinel error and coalescer test helpers. Signed-off-by: Kemal Akkoyun <kemal.akkoyun@datadoghq.com>
1 parent ad397ed commit b137906

2 files changed

Lines changed: 97 additions & 15 deletions

File tree

prometheus/promhttp/http.go

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"fmt"
3838
"io"
3939
"net/http"
40+
"slices"
4041
"strconv"
4142
"sync"
4243
"time"
@@ -96,7 +97,7 @@ type coalescingGatherer struct {
9697
// gatherCycle tracks a single in-flight Gather and all HTTP handlers sharing it.
9798
type gatherCycle struct {
9899
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+
mfs []*dto.MetricFamily // canonical result, set before ready is closed; callers get a slices.Clone, the element values stay shared and must not be mutated
100101
err error // set before ready is closed
101102
done func() // underlying done callback; set before ready is closed
102103
refs int // number of handlers using this cycle; protected by coalescingGatherer.mu
@@ -107,7 +108,7 @@ var _ prometheus.TransactionalGatherer = (*coalescingGatherer)(nil) // compile-t
107108
// errGatherPanicked is returned to callers that joined an in-flight coalesced
108109
// Gather whose underlying gatherer panicked. See the panic guard in Gather for
109110
// why joiners receive this error instead of the panic itself.
110-
var errGatherPanicked = errors.New("promhttp: coalesced gather panicked")
111+
var errGatherPanicked = errors.New("coalesced gather panicked")
111112

112113
func (c *coalescingGatherer) Gather() ([]*dto.MetricFamily, func(), error) {
113114
c.mu.Lock()
@@ -116,7 +117,10 @@ func (c *coalescingGatherer) Gather() ([]*dto.MetricFamily, func(), error) {
116117
cy.refs++
117118
c.mu.Unlock()
118119
<-cy.ready
119-
return cy.mfs, c.releaseFunc(cy), cy.err
120+
// Each caller gets its own slice header so it can filter or reorder
121+
// without racing other callers sharing this cycle. The *dto.MetricFamily
122+
// values remain shared and must not be mutated in place.
123+
return slices.Clone(cy.mfs), c.releaseFunc(cy), cy.err
120124
}
121125
cy := &gatherCycle{
122126
ready: make(chan struct{}),
@@ -136,6 +140,12 @@ func (c *coalescingGatherer) Gather() ([]*dto.MetricFamily, func(), error) {
136140
// set cy.err before closing cy.ready so joiners waiting on <-cy.ready fail
137141
// with that error instead of silently returning an empty, successful
138142
// response, and we clear c.cycle so the next Gather starts a fresh cycle.
143+
//
144+
// The leader never runs its own releaseFunc on this path, so its ref is
145+
// not decremented; that is harmless because the cycle is detached (c.cycle
146+
// = nil) and cy.done is still the no-op set at construction (c.g.Gather
147+
// panicked before assigning a real done). If cy.done is ever made non-nil
148+
// before c.g.Gather runs, this path would need to release it.
139149
panicked := true
140150
defer func() {
141151
if panicked {
@@ -152,7 +162,10 @@ func (c *coalescingGatherer) Gather() ([]*dto.MetricFamily, func(), error) {
152162
panicked = false
153163
close(cy.ready) // happens-before joiners' reads of cy.mfs/err/done
154164

155-
return cy.mfs, c.releaseFunc(cy), cy.err
165+
// Clone here too so cy.mfs stays the write-once canonical slice: joiners
166+
// read it concurrently via slices.Clone, so the leader must not hand out
167+
// (and potentially reorder) the same backing array.
168+
return slices.Clone(cy.mfs), c.releaseFunc(cy), cy.err
156169
}
157170

158171
// releaseFunc returns the done callback for one caller sharing cy.
@@ -535,14 +548,23 @@ type HandlerOpts struct {
535548
// faster than the time collectors need to produce metrics.
536549
//
537550
// When enabled, concurrent scrapers share a single metric snapshot per
538-
// collection cycle. The returned MetricFamily values are shared and must
539-
// not be mutated in place. The built-in handler only reads them, so this
540-
// is safe in practice, but custom TransactionalGatherer implementations
541-
// that modify the returned families after Gather returns must not use
542-
// this option.
551+
// collection cycle. Each request receives its own copy of the returned
552+
// slice, so filtering or reordering it (for example via name[] query
553+
// parameters) is safe. The pointed-to MetricFamily values are still
554+
// shared: the built-in handler only reads them, so this is safe in
555+
// practice, but a custom TransactionalGatherer that mutates the returned
556+
// families in place after Gather returns must not use this option.
557+
//
558+
// Because the snapshot is shared, a request that arrives while a cycle is
559+
// in flight receives that cycle's result even though collection began
560+
// before the request; two scrapers joined to one cycle observe the same
561+
// timestamps rather than independently gathered data.
543562
//
544-
// Consider using CoalesceGather together with Timeout to bound both the
545-
// scrape response time and the number of concurrent background Gathers.
563+
// Consider using CoalesceGather together with Timeout. Timeout bounds the
564+
// client-facing response time and keeps at most one collection running at
565+
// a time, but it does not cancel the underlying Gather: a joined request
566+
// that times out still holds a MaxRequestsInFlight slot until the shared
567+
// collection completes.
546568
//
547569
// Panic handling: a panicking Collector is already turned into an error by
548570
// the registry, so joiners receive that error like any other. In the rare

prometheus/promhttp/http_test.go

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,13 +1046,18 @@ func (c *coalescingGatherer) inflightRefs() int {
10461046
}
10471047

10481048
// waitForRefs blocks until cg reports at least want in-flight refs, failing the
1049-
// test if that does not happen promptly.
1049+
// test if that does not happen within a short deadline. It must be called from
1050+
// the test goroutine, as it reports failure via t.Fatalf.
10501051
func waitForRefs(t *testing.T, cg *coalescingGatherer, want int) {
10511052
t.Helper()
10521053
deadline := time.Now().Add(2 * time.Second)
1053-
for cg.inflightRefs() < want {
1054+
for {
1055+
got := cg.inflightRefs()
1056+
if got >= want {
1057+
return
1058+
}
10541059
if time.Now().After(deadline) {
1055-
t.Fatalf("timed out waiting for %d in-flight refs, have %d", want, cg.inflightRefs())
1060+
t.Fatalf("timed out waiting for %d in-flight refs, have %d", want, got)
10561061
}
10571062
time.Sleep(time.Millisecond)
10581063
}
@@ -1120,7 +1125,62 @@ func TestCoalesceGatherPanicUnblocksJoinersWithError(t *testing.T) {
11201125
if res.done != nil {
11211126
res.done() // must be safe to call: no panic, no double-done.
11221127
}
1128+
// inflightRefs reports 0 once c.cycle is detached, which the leader does on
1129+
// the panic path. This confirms the coalescer starts a fresh cycle next
1130+
// time; it does not track the orphaned cycle's own ref count (the leader's
1131+
// ref is intentionally never released there).
11231132
if got := cg.inflightRefs(); got != 0 {
1124-
t.Errorf("cycle not cleared after panic: inflightRefs = %d, want 0", got)
1133+
t.Errorf("cycle not detached after panic: inflightRefs = %d, want 0", got)
1134+
}
1135+
}
1136+
1137+
// TestCoalesceGatherCallersGetDistinctSlices verifies that callers sharing one
1138+
// cycle each receive their own slice header, so one caller reordering or
1139+
// filtering its slice cannot race another caller of the same cycle.
1140+
func TestCoalesceGatherCallersGetDistinctSlices(t *testing.T) {
1141+
defer goleak.VerifyNone(t)
1142+
1143+
reg := prometheus.NewRegistry()
1144+
cnt := prometheus.NewCounter(prometheus.CounterOpts{Name: "c_total", Help: "help"})
1145+
cnt.Inc()
1146+
reg.MustRegister(cnt)
1147+
block := make(chan struct{})
1148+
started := make(chan struct{}, 1)
1149+
reg.MustRegister(blockingCollector{CollectStarted: started, Block: block})
1150+
1151+
cg := &coalescingGatherer{g: &syncGatherCounter{g: reg}}
1152+
1153+
type result struct {
1154+
mfs []*dto.MetricFamily
1155+
done func()
1156+
}
1157+
got := make(chan result, 2)
1158+
call := func() {
1159+
mfs, done, err := cg.Gather()
1160+
if err != nil {
1161+
t.Errorf("Gather returned error: %v", err)
1162+
}
1163+
got <- result{mfs: mfs, done: done}
1164+
}
1165+
1166+
go call()
1167+
<-started // leader is in-flight, blocked in Collect.
1168+
go call() // second caller joins the same cycle.
1169+
waitForRefs(t, cg, 2) // both callers share the cycle before release.
1170+
close(block)
1171+
1172+
r1 := <-got
1173+
r2 := <-got
1174+
defer r1.done()
1175+
defer r2.done()
1176+
1177+
if len(r1.mfs) == 0 || len(r2.mfs) == 0 {
1178+
t.Fatalf("expected non-empty metric families, got %d and %d", len(r1.mfs), len(r2.mfs))
1179+
}
1180+
// Distinct backing arrays: clearing one caller's slot must not affect the
1181+
// other. This fails if the coalescer hands out the shared slice directly.
1182+
r1.mfs[0] = nil
1183+
if r2.mfs[0] == nil {
1184+
t.Error("callers share a slice backing array; each caller must get its own copy")
11251185
}
11261186
}

0 commit comments

Comments
 (0)