scheduled_task: reuse metrics of unchanged tasks between scrapes (#2637)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-10 16:43:36 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 99c895a761
commit 18d7cc55d9
5 changed files with 545 additions and 42 deletions
+112
View File
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package metriccache reuses const metrics across scrapes for objects whose
// values rarely change.
//
// Building a const metric allocates its label pairs and value, which dominates
// the allocations of collectors that publish many state-set series per object.
// Const metrics are immutable and a registry only reads them during Gather, so
// sending the same metric again in a later scrape is safe.
package metriccache
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
)
// Cache holds the metrics built for each key during the last committed scrape,
// together with the input value they were built from.
//
// The zero value is ready to use. A Cache may be used by concurrent scrapes:
// each scrape reads the last committed generation, which is never modified,
// and the last scrape to commit wins.
type Cache[K, V comparable] struct {
mu sync.Mutex
entries map[K]entry[V]
// resets counts Reset calls, so that a scrape begun before a Reset
// doesn't commit afterwards.
resets uint64
}
type entry[V comparable] struct {
value V
metrics []prometheus.Metric
}
// Scrape collects the entries of one scrape. It must not be shared between
// goroutines.
type Scrape[K, V comparable] struct {
cache *Cache[K, V]
resets uint64
prev map[K]entry[V]
next map[K]entry[V]
}
// Begin starts a scrape based on the last committed generation.
func (c *Cache[K, V]) Begin() Scrape[K, V] {
c.mu.Lock()
prev, resets := c.entries, c.resets
c.mu.Unlock()
return Scrape[K, V]{
cache: c,
resets: resets,
prev: prev,
next: make(map[K]entry[V], len(prev)),
}
}
// Reset drops all cached metrics, for example after their descriptors changed.
// Scrapes begun before Reset don't commit their entries.
func (c *Cache[K, V]) Reset() {
c.mu.Lock()
c.entries = nil
c.resets++
c.mu.Unlock()
}
// Load returns the metrics of the last scrape for key if they were built from
// an equal value, and keeps them for the next scrape. The caller must not
// modify the returned slice.
func (s *Scrape[K, V]) Load(key K, value V) ([]prometheus.Metric, bool) {
e, ok := s.prev[key]
if !ok || e.value != value {
return nil, false
}
s.next[key] = e
return e.metrics, true
}
// Store keeps metrics built from value for key. The caller must not modify
// metrics afterwards.
func (s *Scrape[K, V]) Store(key K, value V, metrics []prometheus.Metric) {
s.next[key] = entry[V]{value: value, metrics: metrics}
}
// Commit replaces the cached entries with those loaded or stored in this
// scrape, so keys that were not seen are dropped. It does nothing if the cache
// was reset after the scrape began, for example by a Close or Build that ran
// while a timed-out scrape was still collecting.
func (s *Scrape[K, V]) Commit() {
s.cache.mu.Lock()
if s.cache.resets == s.resets {
s.cache.entries = s.next
}
s.cache.mu.Unlock()
}
+164
View File
@@ -0,0 +1,164 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package metriccache_test
import (
"strconv"
"sync"
"testing"
"github.com/prometheus-community/windows_exporter/internal/metriccache"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
//nolint:gochecknoglobals
var desc = prometheus.NewDesc("test_state", "test", []string{"name", "state"}, nil)
func build(name, state string) []prometheus.Metric {
return []prometheus.Metric{
prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, 1, name, state),
}
}
func TestCache(t *testing.T) {
t.Parallel()
var cache metriccache.Cache[string, string]
scrape := cache.Begin()
_, ok := scrape.Load("a", "running")
require.False(t, ok, "empty cache")
a := build("a", "running")
scrape.Store("a", "running", a)
scrape.Store("b", "stopped", build("b", "stopped"))
_, ok = scrape.Load("a", "running")
require.False(t, ok, "entries are visible after Commit")
scrape.Commit()
scrape = cache.Begin()
metrics, ok := scrape.Load("a", "running")
require.True(t, ok)
require.Same(t, a[0], metrics[0])
_, ok = scrape.Load("a", "stopped")
require.False(t, ok, "changed value")
// b is neither loaded nor stored in this scrape.
scrape.Commit()
scrape = cache.Begin()
_, ok = scrape.Load("b", "stopped")
require.False(t, ok, "keys not seen in the last scrape are dropped")
metrics, ok = scrape.Load("a", "running")
require.True(t, ok, "loaded keys are kept")
require.Same(t, a[0], metrics[0])
scrape.Commit()
cache.Reset()
scrape = cache.Begin()
_, ok = scrape.Load("a", "running")
require.False(t, ok, "reset")
}
func TestCacheResetDuringScrape(t *testing.T) {
t.Parallel()
var cache metriccache.Cache[string, string]
// A scrape that is still running when the collector is closed or rebuilt
// must not put its metrics back into the cache.
scrape := cache.Begin()
scrape.Store("a", "running", build("a", "running"))
cache.Reset()
scrape.Commit()
scrape = cache.Begin()
_, ok := scrape.Load("a", "running")
require.False(t, ok)
// Scrapes begun after the Reset commit as usual.
scrape.Store("a", "running", build("a", "running"))
scrape.Commit()
scrape = cache.Begin()
_, ok = scrape.Load("a", "running")
require.True(t, ok)
}
func TestCacheConcurrentScrapes(t *testing.T) {
t.Parallel()
var (
cache metriccache.Cache[string, int]
wg sync.WaitGroup
)
for i := range 8 {
wg.Go(func() {
for j := range 100 {
scrape := cache.Begin()
for k := range 10 {
name := strconv.Itoa(k)
value := (i + j) % 3
metrics, ok := scrape.Load(name, value)
if !ok {
metrics = build(name, strconv.Itoa(value))
scrape.Store(name, value, metrics)
}
// require must not be called outside the test goroutine.
assert.Len(t, metrics, 1)
}
scrape.Commit()
}
})
}
wg.Wait()
}
func BenchmarkScrape(b *testing.B) {
var cache metriccache.Cache[string, int]
names := make([]string, 1000)
for i := range names {
names[i] = strconv.Itoa(i)
}
b.ReportAllocs()
for b.Loop() {
scrape := cache.Begin()
for _, name := range names {
if _, ok := scrape.Load(name, 0); !ok {
scrape.Store(name, 0, build(name, "0"))
}
}
scrape.Commit()
}
}