pdh: report duplicate instances by occurrence instead of renaming them (#2742)

Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-16 09:58:43 +02:00
committed by GitHub
parent d8dbf852d1
commit 43d77f6869
32 changed files with 832 additions and 266 deletions
+130 -106
View File
@@ -69,20 +69,25 @@ type Collector[T any] struct {
// Row holds the values of one instance collected by a collector from [NewDynamicCollector].
type Row struct {
// Name is the instance name as reported by Windows.
Name string
// Occurrence counts earlier instances with the same Name in the sample, see [NewCollector].
Occurrence int
// Values holds one value per counter, in the order the counters were passed to [NewDynamicCollector].
// A value is NaN if the counter has no valid value for the instance in this sample.
Values []float64
}
// rowAccessor initializes rows of type T and sets their names and counter values.
// rowAccessor initializes rows of type T and sets their counter values.
// field is the index the counterField of the counter was created with.
type rowAccessor[T any] struct {
// resetRow initializes row for instance. row can hold the values of a
// previous sample; resetRow can reuse its memory.
resetRow func(row *T, instance string)
setName func(row *T, instance string)
setValue func(row *T, field int, value float64)
// resetRow initializes row for the occurrence of instance. row can hold
// the values of a previous sample; resetRow can reuse its memory.
resetRow func(row *T, instance string, occurrence int)
// keepsOccurrences is true if rows record their occurrence. Otherwise, only
// the first instance of each name is collected.
keepsOccurrences bool
setValue func(row *T, field int, value float64)
}
// counterField maps a counter to the field index passed to rowAccessor.setValue.
@@ -112,9 +117,18 @@ type Counter struct {
// NewCollector creates a collector for the counters declared by the perfdata tags of the struct T.
//
// The optional field Name (string) of T receives the instance name. Occurrence
// suffixes distinguish duplicate names without colliding with literal names.
// These suffixes are not stable identities across samples.
// The optional field Name (string) of T receives the instance name exactly as
// Windows reports it. Some objects report the same name for several instances
// in one sample, e.g. Hyper-V VMs with the same name. The optional field
// Occurrence (int) of T receives the number of earlier instances with the same
// name in the sample: 0 for the first, 1 for the second, and so on. It follows
// the order of the counter arrays, so it is not a stable identity across
// samples. A suffix such as "#2" in Name was reported by Windows, never added
// by this package. If T has no Occurrence field, only the first instance of
// each name that has valid data is collected, so that the rows have unique
// names. Dropping instances is logged once per collector as a warning; a row
// type for an object that can report a name more than once must declare
// Occurrence.
//
// An instance is only collected if every counter has a valid value for it in that
// sample. Use [Collector.MetricType] for the metric type of a counter.
@@ -167,6 +181,14 @@ func structFields[T any]() ([]counterField, rowAccessor[T], []error, error) {
}
}
occurrenceOffset := -1
if f, ok := valueType.FieldByName("Occurrence"); ok && f.Type.Kind() == reflect.Int {
if offset, ok := fieldOffset(valueType, f.Index); ok {
occurrenceOffset = offset
}
}
var errs []error
fields := make([]counterField, 0, valueType.NumField())
@@ -217,7 +239,7 @@ func structFields[T any]() ([]counterField, rowAccessor[T], []error, error) {
return nil, rowAccessor[T]{}, nil, err
}
return fields, structRowAccessor[T](nameOffset), errs, nil
return fields, structRowAccessor[T](nameOffset, occurrenceOffset), errs, nil
}
// validateFloatOffsets checks that the index of every field is the byte
@@ -267,25 +289,26 @@ func fieldOffset(t reflect.Type, index []int) (int, bool) {
}
// structRowAccessor returns the accessor of the struct T. nameOffset is the
// byte offset of its string field Name, or -1. The offsets passed to setValue
// byte offset of its string field Name, occurrenceOffset that of its int field
// Occurrence, each -1 if T has no such field. The offsets passed to setValue
// come from [structFields] and address float64 fields of T, so values are
// written without reflection.
func structRowAccessor[T any](nameOffset int) rowAccessor[T] {
setName := func(row *T, instance string) {
if nameOffset >= 0 {
*(*string)(unsafe.Add(unsafe.Pointer(row), nameOffset)) = instance
}
}
func structRowAccessor[T any](nameOffset, occurrenceOffset int) rowAccessor[T] {
return rowAccessor[T]{
resetRow: func(row *T, instance string) {
resetRow: func(row *T, instance string, occurrence int) {
var zero T
*row = zero
setName(row, instance)
if nameOffset >= 0 {
*(*string)(unsafe.Add(unsafe.Pointer(row), nameOffset)) = instance
}
if occurrenceOffset >= 0 {
*(*int)(unsafe.Add(unsafe.Pointer(row), occurrenceOffset)) = occurrence
}
},
setName: setName,
keepsOccurrences: occurrenceOffset >= 0,
setValue: func(row *T, offset int, value float64) {
*(*float64)(unsafe.Add(unsafe.Pointer(row), offset)) = value
},
@@ -309,8 +332,9 @@ func NewDynamicCollector(logger *slog.Logger, resultType CounterType, object str
// keeps its Values array if it is large enough.
func dynamicRowAccessor(n int) rowAccessor[Row] {
return rowAccessor[Row]{
resetRow: func(row *Row, instance string) {
resetRow: func(row *Row, instance string, occurrence int) {
row.Name = instance
row.Occurrence = occurrence
if cap(row.Values) < n {
row.Values = make([]float64, n)
@@ -321,9 +345,7 @@ func dynamicRowAccessor(n int) rowAccessor[Row] {
row.Values = row.Values[:n]
clear(row.Values)
},
setName: func(row *Row, instance string) {
row.Name = instance
},
keepsOccurrences: true,
setValue: func(row *Row, field int, value float64) {
row.Values[field] = value
},
@@ -613,11 +635,17 @@ type collectState struct {
names instanceNameCache
// index, occurrences and seen back the maps of rowSet. They are cleared
// for every sample, so their buckets are not allocated again.
index map[instanceKey]int
occurrences map[string]int
seen map[string]int
// index and seen back the maps of rowSet. They are cleared for every
// sample, so their buckets are not allocated again.
index map[instanceKey]int
seen map[string]int
// keys backs rowSet.keys.
keys []instanceKey
// duplicatesLogged is true once a sample dropped duplicate instances of a
// row type without an Occurrence field.
duplicatesLogged bool
}
// collect replaces the content of dst with one sample.
@@ -675,6 +703,8 @@ func (c *Collector[T]) collect(dst *[]T, state *collectState) (err error) {
rows.finish()
state.keys = rows.keys
state.valid = rows.valid
if len(*dst) == 0 {
@@ -810,12 +840,16 @@ type rowSet[T any] struct {
dst *[]T
index map[instanceKey]int
nameCache *instanceNameCache
// occurrences includes invalid items to reserve all literal names and keep occurrence positions.
occurrences map[string]int
// seen counts names in the current counter array and is cleared between arrays.
seen map[string]int
// valid has len(c.counters) entries per row.
valid []bool
// keys has the instance key of each row.
keys []instanceKey
// duplicates is true if a row has an occurrence above 0.
duplicates bool
// duplicatesLogged points to collectState.duplicatesLogged.
duplicatesLogged *bool
}
func newRowSet[T any](c *Collector[T], dst *[]T, state *collectState) rowSet[T] {
@@ -823,23 +857,23 @@ func newRowSet[T any](c *Collector[T], dst *[]T, state *collectState) rowSet[T]
if state.index == nil {
state.index = map[instanceKey]int{}
state.occurrences = map[string]int{}
state.seen = map[string]int{}
} else {
// A failed sample can leave entries behind.
clear(state.index)
clear(state.occurrences)
clear(state.seen)
}
return rowSet[T]{
c: c,
dst: dst,
index: state.index,
nameCache: &state.names,
occurrences: state.occurrences,
seen: state.seen,
valid: state.valid[:0],
c: c,
dst: dst,
index: state.index,
nameCache: &state.names,
seen: state.seen,
valid: state.valid[:0],
keys: state.keys[:0],
duplicatesLogged: &state.duplicatesLogged,
}
}
@@ -855,7 +889,6 @@ func (r *rowSet[T]) row(counter *Counter, szName *uint16, status uint32) (int, b
// Count before checking status so invalid items do not shift subsequent duplicates.
occurrence := r.seen[instanceName]
r.seen[instanceName]++
r.occurrences[instanceName] = max(r.occurrences[instanceName], occurrence+1)
if status != CstatusValidData && status != CstatusNewData {
// Check the level first, so the attributes are not built for every
@@ -893,8 +926,10 @@ func (r *rowSet[T]) row(counter *Counter, szName *uint16, status uint32) (int, b
rows = append(rows, zero)
}
r.c.rows.resetRow(&rows[index], instanceName)
r.c.rows.resetRow(&rows[index], instanceName, occurrence)
*r.dst = rows
r.keys = append(r.keys, key)
r.duplicates = r.duplicates || occurrence > 0
n := len(r.c.counters)
r.valid = slices.Grow(r.valid, n)[:len(r.valid)+n]
@@ -943,37 +978,11 @@ func (r *rowSet[T]) addFormattedItems(counterIndex int, buf []byte, itemCount ui
}
}
// nameDuplicates assigns suffixes after all literal names in the sample are known.
func (r *rowSet[T]) nameDuplicates() {
for name, count := range r.occurrences {
suffix := 0
for occurrence := 1; occurrence < count; occurrence++ {
var instanceName string
for {
suffix++
instanceName = r.nameCache.suffixed(name, suffix)
if _, literalName := r.occurrences[instanceName]; !literalName {
break
}
}
if row, ok := r.index[instanceKey{name: name, occurrence: occurrence}]; ok {
r.c.rows.setName(&(*r.dst)[row], instanceName)
}
}
}
}
// finish handles rows that lack a valid value for some counter. Counters
// without any instance, e.g. those skipped by perfdata_min_build, are not
// required. Without partial rows, such rows are removed. Otherwise, the
// missing values are set to NaN.
func (r *rowSet[T]) finish() {
r.nameDuplicates()
rows := *r.dst
n := len(r.c.counters)
kept := 0
@@ -1007,6 +1016,7 @@ func (r *rowSet[T]) finish() {
if row != kept {
rows[kept] = rows[row]
r.keys[kept] = r.keys[row]
// Do not leave two rows that share a Row.Values array.
var zero T
@@ -1022,10 +1032,55 @@ func (r *rowSet[T]) finish() {
slog.String("object", r.c.object),
slog.Int("instances", dropped),
)
clear(rows[kept:])
*r.dst = rows[:kept]
}
r.keys = r.keys[:kept]
if r.duplicates && !r.c.rows.keepsOccurrences {
kept = r.keepFirstOccurrences(rows[:kept])
}
clear(rows[kept:])
*r.dst = rows[:kept]
}
// keepFirstOccurrences keeps, for row types without an Occurrence field, the
// first instance of each name among the rows that remain after finish, so
// that an instance without valid data does not hide a later one with the
// same name. It returns the number of rows kept at the start of rows.
func (r *rowSet[T]) keepFirstOccurrences(rows []T) int {
first := make(map[string]int, len(rows))
for i, key := range r.keys {
if j, ok := first[key.name]; !ok || key.occurrence < r.keys[j].occurrence {
first[key.name] = i
}
}
kept := 0
for i, key := range r.keys {
if first[key.name] != i {
continue
}
rows[kept] = rows[i]
r.keys[kept] = key
kept++
}
if dropped := len(rows) - kept; dropped > 0 && !*r.duplicatesLogged {
*r.duplicatesLogged = true
r.c.logger.Warn("Windows reported instance names more than once, only the first instance of each name is collected",
slog.String("object", r.c.object),
slog.Int("instances", dropped),
)
}
r.keys = r.keys[:kept]
return kept
}
// panicError logs a panic recovered while collecting and returns it as an error.
@@ -1098,11 +1153,8 @@ func formatCounterPath(object, instance, counterName string) string {
// Names that a sample did not decode are removed at its end, because instance names that
// contain process IDs, like those of GPU Engine, change all the time.
type instanceNameCache struct {
names map[string]*cachedInstanceName
// suffixedNames maps a name and an occurrence suffix to the name with
// the suffix, e.g. svchost and 3 to svchost#3.
suffixedNames map[instanceKey]*cachedInstanceName
sample uint64
names map[string]*cachedInstanceName
sample uint64
}
type cachedInstanceName struct {
@@ -1146,41 +1198,13 @@ func (cache *instanceNameCache) decode(p *uint16) string {
return name
}
// suffixed returns name with the occurrence suffix #suffix. Duplicate names,
// like those of the many svchost processes, need the same suffixed names in
// every sample.
func (cache *instanceNameCache) suffixed(name string, suffix int) string {
key := instanceKey{name: name, occurrence: suffix}
if cached, ok := cache.suffixedNames[key]; ok {
cached.sample = cache.sample
return cached.name
}
if cache.suffixedNames == nil {
cache.suffixedNames = map[instanceKey]*cachedInstanceName{}
}
suffixedName := name + "#" + strconv.Itoa(suffix)
cache.suffixedNames[key] = &cachedInstanceName{name: suffixedName, sample: cache.sample}
return suffixedName
}
// removeUnseen removes the names that the current sample did not use.
// removeUnseen removes the names that the current sample did not decode.
func (cache *instanceNameCache) removeUnseen() {
for raw, cached := range cache.names {
if cached.sample != cache.sample {
delete(cache.names, raw)
}
}
for key, cached := range cache.suffixedNames {
if cached.sample != cache.sample {
delete(cache.suffixedNames, key)
}
}
}
// isKnownCounterDataStatus reports whether status means that a counter has no
+143 -85
View File
@@ -24,6 +24,7 @@ import (
"reflect"
"runtime"
"slices"
"strings"
"testing"
"unsafe"
@@ -38,6 +39,8 @@ type rowSetValues struct {
B float64 `perfdata:"B"`
BSecond float64 `perfdata:"B,secondvalue"`
Optional float64 `perfdata:"Optional"`
// Occurrence is last, so that the field indices of the counters stay the same.
Occurrence int
}
// newRowSetTestCollector returns a collector with the counters A and B, which
@@ -48,7 +51,7 @@ func newRowSetTestCollector(partialRows bool) *Collector[rowSetValues] {
return &Collector[rowSetValues]{
partialRows: partialRows,
logger: slog.New(slog.DiscardHandler),
rows: structRowAccessor[rowSetValues](int(unsafe.Offsetof(row.Name))),
rows: structRowAccessor[rowSetValues](int(unsafe.Offsetof(row.Name)), int(unsafe.Offsetof(row.Occurrence))),
counters: []Counter{
{Name: "A", Instances: map[string]pdhCounterHandle{"*": 1}, FieldIndexValue: int(unsafe.Offsetof(row.A)), FieldIndexSecondValue: -1},
{Name: "B", Instances: map[string]pdhCounterHandle{"*": 2}, FieldIndexValue: int(unsafe.Offsetof(row.B)), FieldIndexSecondValue: int(unsafe.Offsetof(row.BSecond))},
@@ -121,6 +124,15 @@ func TestRowSetDuplicateInstances(t *testing.T) {
nan := math.NaN()
// wantRow identifies a row by name and occurrence. suffixed is the name
// that SuffixDuplicates gives the row.
type wantRow struct {
name string
occurrence int
suffixed string
a, b float64
}
for _, tc := range []struct {
name string
names []string
@@ -128,17 +140,17 @@ func TestRowSetDuplicateInstances(t *testing.T) {
a []float64
b []float64
repeatArray bool
want []rowSetValues
want []wantRow
}{
{
name: "duplicates",
names: []string{"A", "A", "A"},
a: []float64{1, 2, 3},
b: []float64{10, 20, 30},
want: []rowSetValues{
{Name: "A", A: 1, B: 10},
{Name: "A#1", A: 2, B: 20},
{Name: "A#2", A: 3, B: 30},
want: []wantRow{
{name: "A", suffixed: "A", a: 1, b: 10},
{name: "A", occurrence: 1, suffixed: "A#1", a: 2, b: 20},
{name: "A", occurrence: 2, suffixed: "A#2", a: 3, b: 30},
},
},
{
@@ -147,22 +159,23 @@ func TestRowSetDuplicateInstances(t *testing.T) {
secondNames: []string{"B", "A", "A"},
a: []float64{1, 2, 3},
b: []float64{20, 10, 30},
want: []rowSetValues{
{Name: "A", A: 1, B: 10},
{Name: "B", A: 2, B: 20},
{Name: "A#1", A: 3, B: 30},
want: []wantRow{
{name: "A", suffixed: "A", a: 1, b: 10},
{name: "B", suffixed: "B", a: 2, b: 20},
{name: "A", occurrence: 1, suffixed: "A#1", a: 3, b: 30},
},
},
{
// A name with a suffix reported by Windows is a different instance.
name: "literal_suffix",
names: []string{"A", "A", "A#1"},
secondNames: []string{"A#1", "A", "A"},
a: []float64{1, 2, 3},
b: []float64{30, 10, 20},
want: []rowSetValues{
{Name: "A", A: 1, B: 10},
{Name: "A#2", A: 2, B: 20},
{Name: "A#1", A: 3, B: 30},
want: []wantRow{
{name: "A", suffixed: "A", a: 1, b: 10},
{name: "A", occurrence: 1, suffixed: "A#2", a: 2, b: 20},
{name: "A#1", suffixed: "A#1", a: 3, b: 30},
},
},
{
@@ -170,10 +183,10 @@ func TestRowSetDuplicateInstances(t *testing.T) {
names: []string{"A", "A", "A"},
a: []float64{1, nan, 3},
b: []float64{10, 20, 30},
want: []rowSetValues{
{Name: "A", A: 1, B: 10},
{Name: "A#1", A: nan, B: 20},
{Name: "A#2", A: 3, B: 30},
want: []wantRow{
{name: "A", suffixed: "A", a: 1, b: 10},
{name: "A", occurrence: 1, suffixed: "A#1", a: nan, b: 20},
{name: "A", occurrence: 2, suffixed: "A#2", a: 3, b: 30},
},
},
{
@@ -181,20 +194,21 @@ func TestRowSetDuplicateInstances(t *testing.T) {
names: []string{"A", "A", "A"},
a: []float64{1, 2, 3},
b: []float64{nan, 20, 30},
want: []rowSetValues{
{Name: "A", A: 1, B: nan},
{Name: "A#1", A: 2, B: 20},
{Name: "A#2", A: 3, B: 30},
want: []wantRow{
{name: "A", suffixed: "A", a: 1, b: nan},
{name: "A", occurrence: 1, suffixed: "A#1", a: 2, b: 20},
{name: "A", occurrence: 2, suffixed: "A#2", a: 3, b: 30},
},
},
{
// The invalid "A#1" has no row, so its suffix is free.
name: "invalid_literal_suffix",
names: []string{"A", "A", "A#1"},
a: []float64{1, 2, nan},
b: []float64{10, 20, nan},
want: []rowSetValues{
{Name: "A", A: 1, B: 10},
{Name: "A#2", A: 2, B: 20},
want: []wantRow{
{name: "A", suffixed: "A", a: 1, b: 10},
{name: "A", occurrence: 1, suffixed: "A#1", a: 2, b: 20},
},
},
{
@@ -203,77 +217,92 @@ func TestRowSetDuplicateInstances(t *testing.T) {
a: []float64{1, 2},
b: []float64{10, 20},
repeatArray: true,
want: []rowSetValues{
{Name: "A", A: 1, B: 10},
{Name: "A#1", A: 2, B: 20},
want: []wantRow{
{name: "A", suffixed: "A", a: 1, b: 10},
{name: "A", occurrence: 1, suffixed: "A#1", a: 2, b: 20},
},
},
} {
for _, resultType := range []CounterType{CounterTypeRaw, CounterTypeFormatted} {
for _, partialRows := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/%s/partial=%t", tc.name, resultType, partialRows), func(t *testing.T) {
t.Parallel()
for _, keepsOccurrences := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/%s/partial=%t/occurrences=%t", tc.name, resultType, partialRows, keepsOccurrences), func(t *testing.T) {
t.Parallel()
c := newRowSetTestCollector(partialRows)
c.counters[1].FieldIndexSecondValue = -1
c := newRowSetTestCollector(partialRows)
c.counters[1].FieldIndexSecondValue = -1
c.rows.keepsOccurrences = keepsOccurrences
var dst []rowSetValues
var dst []rowSetValues
rows := newRowSet(c, &dst, &collectState{})
rows := newRowSet(c, &dst, &collectState{})
addDuplicateTestItems(
&rows,
resultType,
0,
tc.names,
tc.a,
)
addDuplicateTestItems(&rows, resultType, 0, tc.names, tc.a)
if tc.repeatArray {
addDuplicateTestItems(
&rows,
resultType,
0,
tc.names,
tc.a,
)
}
secondNames := tc.secondNames
if secondNames == nil {
secondNames = tc.names
}
addDuplicateTestItems(
&rows,
resultType,
1,
secondNames,
tc.b,
)
rows.finish()
want := slices.DeleteFunc(slices.Clone(tc.want), func(row rowSetValues) bool {
return !partialRows && (math.IsNaN(row.A) || math.IsNaN(row.B))
})
require.Len(t, dst, len(want))
for _, expected := range want {
i := slices.IndexFunc(dst, func(row rowSetValues) bool { return row.Name == expected.Name })
require.NotEqual(t, -1, i, "missing instance %s", expected.Name)
for field, value := range map[string]float64{"A": expected.A, "B": expected.B} {
got := reflect.ValueOf(dst[i]).FieldByName(field).Float()
if math.IsNaN(value) {
require.True(t, math.IsNaN(got), "%s/%s: %v", expected.Name, field, got)
continue
}
require.InDelta(t, value, got, 0, "%s/%s", expected.Name, field)
if tc.repeatArray {
addDuplicateTestItems(&rows, resultType, 0, tc.names, tc.a)
}
}
})
secondNames := tc.secondNames
if secondNames == nil {
secondNames = tc.names
}
addDuplicateTestItems(&rows, resultType, 1, secondNames, tc.b)
rows.finish()
want := slices.DeleteFunc(slices.Clone(tc.want), func(row wantRow) bool {
return !partialRows && (math.IsNaN(row.a) || math.IsNaN(row.b))
})
if !keepsOccurrences {
// Without an Occurrence field, only the first remaining instance of a name is collected.
seen := map[string]bool{}
want = slices.DeleteFunc(want, func(row wantRow) bool {
duplicate := seen[row.name]
seen[row.name] = true
return duplicate
})
}
require.Len(t, dst, len(want))
for _, expected := range want {
i := slices.IndexFunc(dst, func(row rowSetValues) bool {
return row.Name == expected.name && row.Occurrence == expected.occurrence
})
require.NotEqual(t, -1, i, "missing instance %s occurrence %d", expected.name, expected.occurrence)
for field, value := range map[string]float64{"A": expected.a, "B": expected.b} {
got := reflect.ValueOf(dst[i]).FieldByName(field).Float()
if math.IsNaN(value) {
require.True(t, math.IsNaN(got), "%s/%s: %v", expected.name, field, got)
continue
}
require.InDelta(t, value, got, 0, "%s/%s", expected.name, field)
}
}
if !keepsOccurrences {
return
}
// SuffixDuplicates gives every row a unique name.
SuffixDuplicates(dst,
func(row *rowSetValues) *string { return &row.Name },
func(row *rowSetValues) int { return row.Occurrence },
)
for _, expected := range want {
i := slices.IndexFunc(dst, func(row rowSetValues) bool { return row.Name == expected.suffixed })
require.NotEqual(t, -1, i, "missing suffixed instance %s", expected.suffixed)
require.Equal(t, expected.occurrence, dst[i].Occurrence, expected.suffixed)
}
})
}
}
}
}
@@ -563,6 +592,35 @@ func TestInstanceNameCache(t *testing.T) {
// TestRowSetReusesState checks that a sample does not see the instances of an
// earlier sample that shared its state, even if that sample did not finish.
// TestRowSetWarnsOnceAboutDroppedDuplicates checks that dropping duplicate
// instances of a row type without an Occurrence field is logged once.
func TestRowSetWarnsOnceAboutDroppedDuplicates(t *testing.T) {
t.Parallel()
var logs strings.Builder
c := newRowSetTestCollector(true)
c.logger = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn}))
c.rows.keepsOccurrences = false
state := &collectState{}
for range 3 {
var dst []rowSetValues
rows := newRowSet(c, &dst, state)
addDuplicateTestItems(&rows, CounterTypeRaw, 0, []string{"a", "a", "b"}, []float64{1, 2, 3})
addDuplicateTestItems(&rows, CounterTypeRaw, 1, []string{"a", "a", "b"}, []float64{4, 5, 6})
rows.finish()
state.keys = rows.keys
require.Len(t, dst, 2)
require.InDelta(t, 1, dst[0].A, 0, "the first instance of a is kept")
}
require.Equal(t, 1, strings.Count(logs.String(), "level=WARN"), logs.String())
}
func TestRowSetReusesState(t *testing.T) {
t.Parallel()
+36 -35
View File
@@ -69,16 +69,15 @@ func TestStructFields(t *testing.T) {
var row structFieldsValues
row.Invalid = 42
rows.resetRow(&row, "instance")
rows.resetRow(&row, "instance", 0)
require.Equal(t, structFieldsValues{Name: "instance"}, row)
rows.setValue(&row, offsets["Inner"], 1)
rows.setValue(&row, offsets["Outer"], 2)
rows.setValue(&row, offsets["Outer,secondvalue"], 3)
rows.setName(&row, "renamed")
require.Equal(t, structFieldsValues{
Name: "renamed",
Name: "instance",
Inner: 1,
Outer: 2,
Base: 3,
@@ -172,9 +171,9 @@ func TestStructRowAccessorWithoutName(t *testing.T) {
require.Len(t, fields, 1)
row := values{Value: 1}
rows.resetRow(&row, "instance")
rows.setName(&row, "instance")
rows.resetRow(&row, "instance", 1)
require.Equal(t, values{}, row)
require.False(t, rows.keepsOccurrences)
rows.setValue(&row, fields[0].index, 2)
require.Equal(t, values{Value: 2}, row)
@@ -188,20 +187,44 @@ func TestDynamicRowAccessor(t *testing.T) {
rows := dynamicRowAccessor(2)
values := []float64{1, 2, 3}
row := Row{Name: "old", Values: values}
row := Row{Name: "old", Occurrence: 3, Values: values}
rows.resetRow(&row, "new")
require.Equal(t, Row{Name: "new", Values: []float64{0, 0}}, row)
rows.resetRow(&row, "new", 1)
require.Equal(t, Row{Name: "new", Occurrence: 1, Values: []float64{0, 0}}, row)
require.Same(t, unsafe.SliceData(values), unsafe.SliceData(row.Values), "Values allocated again")
rows.setValue(&row, 1, 4)
require.Equal(t, []float64{0, 4}, row.Values)
row = Row{Name: "old", Values: []float64{1}}
rows.resetRow(&row, "new")
row = Row{Name: "old", Occurrence: 2, Values: []float64{1}}
rows.resetRow(&row, "new", 0)
require.Equal(t, Row{Name: "new", Values: []float64{0, 0}}, row)
}
// TestStructRowAccessorOccurrence checks that a reused struct row gets the
// occurrence of its new instance, not that of the previous sample.
func TestStructRowAccessorOccurrence(t *testing.T) {
t.Parallel()
type values struct {
Name string
Value float64 `perfdata:"Value"`
Occurrence int
}
_, rows, errs, err := structFields[values]()
require.NoError(t, err)
require.Empty(t, errs)
require.True(t, rows.keepsOccurrences)
row := values{Name: "old", Value: 1, Occurrence: 3}
rows.resetRow(&row, "new", 1)
require.Equal(t, values{Name: "new", Occurrence: 1}, row)
rows.resetRow(&row, "first", 0)
require.Equal(t, values{Name: "first"}, row)
}
// TestRowSetReusesRows checks that a sample reuses the backing array of dst
// and that no value of an earlier sample remains in a reused row.
func TestRowSetReusesRows(t *testing.T) {
@@ -260,42 +283,20 @@ func TestRowSetSampleDoesNotAllocate(t *testing.T) {
rows.finish()
state.names.removeUnseen()
state.keys = rows.keys
state.valid = rows.valid
}
sample()
require.Len(t, dst, instances)
require.Equal(t, "instance_A#3", dst[75].Name)
require.Equal(t, "instance_A", dst[75].Name)
require.Equal(t, 3, dst[75].Occurrence)
require.Zero(t, testing.AllocsPerRun(10, sample))
runtime.KeepAlive(items)
}
// TestInstanceNameCacheSuffixed checks that suffixed names are built once
// while their instances exist.
func TestInstanceNameCacheSuffixed(t *testing.T) {
t.Parallel()
var cache instanceNameCache
cache.startSample()
first := cache.suffixed("svchost", 2)
require.Equal(t, "svchost#2", first)
require.Equal(t, "svchost#3", cache.suffixed("svchost", 3))
cache.removeUnseen()
cache.startSample()
second := cache.suffixed("svchost", 2)
require.Same(t, unsafe.StringData(first), unsafe.StringData(second), "name built again")
cache.removeUnseen()
require.Len(t, cache.suffixedNames, 1, "name of a vanished instance kept")
require.Contains(t, cache.suffixedNames, instanceKey{name: "svchost", occurrence: 2})
}
// TestCollectReusesRows checks that Collect keeps the backing array of dst and
// releases the rows a sample does not use.
func TestCollectReusesRows(t *testing.T) {
@@ -60,6 +60,7 @@ func BenchmarkRowSetInstanceNames(b *testing.B) {
rows.finish()
state.names.removeUnseen()
state.keys = rows.keys
state.valid = rows.valid
}
@@ -171,6 +172,7 @@ func BenchmarkRowSetProcessShape(b *testing.B) {
rows.finish()
state.names.removeUnseen()
state.keys = rows.keys
state.valid = rows.valid
}
+64
View File
@@ -74,6 +74,70 @@ func TestCollector(t *testing.T) {
}
}
type processOccurrence struct {
Name string
Occurrence int
ProcessID float64 `perfdata:"ID Process"`
}
// TestCollectorOccurrences checks the duplicate names of the Process object,
// which reports every svchost process as "svchost".
func TestCollectorOccurrences(t *testing.T) {
t.Parallel()
logger := slog.New(slog.DiscardHandler)
withOccurrence, err := pdh.NewCollector[processOccurrence](logger, pdh.CounterTypeRaw, "Process", pdh.InstancesAll)
require.NoError(t, err)
defer withOccurrence.Close()
withoutOccurrence, err := pdh.NewCollector[process](logger, pdh.CounterTypeRaw, "Process", pdh.InstancesAll)
require.NoError(t, err)
defer withoutOccurrence.Close()
var rows []processOccurrence
require.NoError(t, withOccurrence.Collect(&rows))
occurrences := map[string][]int{}
for _, row := range rows {
occurrences[row.Name] = append(occurrences[row.Name], row.Occurrence)
}
require.Greater(t, len(occurrences["svchost"]), 1, "svchost is reported more than once")
for name, seen := range occurrences {
require.NotContains(t, name, "#", "the collector adds no suffix")
require.ElementsMatch(t, seen, seq(len(seen)), "occurrences of %s", name)
}
var unique []process
require.NoError(t, withoutOccurrence.Collect(&unique))
names := map[string]int{}
for _, row := range unique {
names[row.Name]++
}
require.Equal(t, 1, names["svchost"], "without an Occurrence field only the first instance of a name is collected")
for name, count := range names {
require.Equal(t, 1, count, name)
}
}
func seq(n int) []int {
s := make([]int, n)
for i := range s {
s[i] = i
}
return s
}
func TestCollectorConcurrentCollect(t *testing.T) {
t.Parallel()
+69
View File
@@ -0,0 +1,69 @@
// 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.
//go:build windows
package pdh
import "strconv"
// SuffixDuplicates makes the names of rows unique within a sample for
// collectors that publish every occurrence of a duplicate instance name.
//
// name returns a pointer to the name of a row, occurrence its Occurrence (see
// [NewCollector]). The first occurrence keeps its name. Later occurrences get
// the suffix "#N", where N counts from 1 and skips every N for which
// "<name>#N" is the name of another row, for example a suffix that Windows
// added itself. Only the names of rows reserve suffixes: an instance that
// Windows reported without valid data has no row and reserves nothing. N
// depends on the order of the sample and is not a stable identity.
//
// Collectors can map names, e.g. an empty name to a placeholder, before they
// call SuffixDuplicates, so that the suffix is added to the published name.
func SuffixDuplicates[T any](rows []T, name func(*T) *string, occurrence func(*T) int) {
var names map[string]struct{}
for i := range rows {
if occurrence(&rows[i]) == 0 {
continue
}
if names == nil {
names = make(map[string]struct{}, len(rows))
for j := range rows {
names[*name(&rows[j])] = struct{}{}
}
}
instanceName := name(&rows[i])
base := *instanceName
remaining := occurrence(&rows[i])
for suffix := 1; ; suffix++ {
candidate := base + "#" + strconv.Itoa(suffix)
if _, literal := names[candidate]; literal {
continue
}
remaining--
if remaining == 0 {
*instanceName = candidate
break
}
}
}
}