diff --git a/label_namer.go b/label_namer.go index 368ceda..8c6b241 100644 --- a/label_namer.go +++ b/label_namer.go @@ -23,6 +23,9 @@ import ( "errors" "fmt" "strings" + "sync" + "sync/atomic" + "time" "unicode" ) @@ -46,6 +49,13 @@ type LabelNamer struct { // specification https://github.com/open-telemetry/opentelemetry-specification/blob/v1.38.0/specification/compatibility/prometheus_and_openmetrics.md#otlp-metric-points-to-prometheus), // but may be needed for compatibility with legacy systems that rely on the old behavior. PreserveMultipleUnderscores bool + // CacheDisabled specifies whether to disable the transformation cache. + // Defaults to false (cache enabled). Set to true to disable caching. + CacheDisabled bool + // once ensures thread-safe lazy initialization of cache. + once sync.Once + // cache is lazily initialized when CacheDisabled is false. + cache *StringCache } // Build normalizes the specified label to follow Prometheus label names standard. @@ -74,6 +84,61 @@ func (ln *LabelNamer) Build(label string) (string, error) { return label, nil } + // Lazy init cache if enabled (thread-safe via sync.Once) + if !ln.CacheDisabled { + ln.once.Do(func() { + ln.cache = NewStringCache() + }) + } + + // Try cache first + if ln.cache != nil { + if v, ok := ln.cache.m.Load(label); ok { + e := v.(*cacheEntry) + ct := uint64(time.Now().Unix()) + if e.lastAccessTime.Load()+10 < ct { + e.lastAccessTime.Store(ct) + } + return e.value, nil + } + } + + // Cache miss - transform + result, err := ln.buildWithoutCache(label) + if err != nil { + return result, err + } + + // Store in cache with memory safety + if ln.cache != nil { + label = strings.Clone(label) + if result == label { + result = label + } + e := &cacheEntry{ + value: result, + } + e.lastAccessTime.Store(uint64(time.Now().Unix())) + ln.cache.m.Store(label, e) + + // Lazy cleanup + ct := uint64(time.Now().Unix()) + if needCleanup(&ln.cache.lastCleanupTime, ct) { + deadline := ct - uint64(ln.cache.expireDuration.Seconds()) + ln.cache.m.Range(func(k, v any) bool { + e := v.(*cacheEntry) + if e.lastAccessTime.Load() < deadline { + ln.cache.m.Delete(k) + } + return true + }) + } + } + + return result, nil +} + +func (ln *LabelNamer) buildWithoutCache(label string) (string, error) { normalizedName := sanitizeLabelName(label, ln.PreserveMultipleUnderscores) // If label starts with a number, prepend with "key_". @@ -98,3 +163,33 @@ func hasUnderscoresOnly(label string) bool { } return true } + +// StringCache caches string transformations. +// It is safe for concurrent use. +type StringCache struct { + m sync.Map + lastCleanupTime atomic.Uint64 + expireDuration time.Duration +} + +type cacheEntry struct { + lastAccessTime atomic.Uint64 + value string +} + +// NewStringCache creates a new StringCache with default expiry duration. +func NewStringCache() *StringCache { + return &StringCache{ + expireDuration: 6 * time.Minute, + } +} + +// needCleanup returns true if cleanup should be performed. +// It is called lazily on Transform to avoid background goroutines. +func needCleanup(lastCleanupTime *atomic.Uint64, currentTime uint64) bool { + lct := lastCleanupTime.Load() + if lct+61 >= currentTime { + return false + } + return lastCleanupTime.CompareAndSwap(lct, currentTime) +} diff --git a/label_namer_bench_test.go b/label_namer_bench_test.go index d39f2ab..3a09265 100644 --- a/label_namer_bench_test.go +++ b/label_namer_bench_test.go @@ -55,3 +55,22 @@ func BenchmarkNormalizeLabel(b *testing.B) { }) } } + +func BenchmarkNormalizeLabelWithCache(b *testing.B) { + labelNamer := LabelNamer{CacheDisabled: false} + // Pre-populate cache + for _, input := range labelBenchmarkInputs { + //nolint:errcheck + labelNamer.Build(input.label) + } + + b.ResetTimer() + for _, input := range labelBenchmarkInputs { + b.Run(input.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + //nolint:errcheck + labelNamer.Build(input.label) + } + }) + } +} diff --git a/label_namer_test.go b/label_namer_test.go index 6e3d5ce..fe471c9 100644 --- a/label_namer_test.go +++ b/label_namer_test.go @@ -215,3 +215,80 @@ func TestBuildLabel_UTF8Allowed(t *testing.T) { }) } } + +func TestLabelNamerCacheHit(t *testing.T) { + namer := &LabelNamer{CacheDisabled: false} + + result1, err := namer.Build("http.method") + if err != nil { + t.Fatal(err) + } + if result1 != "http_method" { + t.Errorf("expected http_method, got %s", result1) + } + + // Same label should hit cache + result2, err := namer.Build("http.method") + if err != nil { + t.Fatal(err) + } + if result2 != "http_method" { + t.Errorf("expected http_method, got %s", result2) + } +} + +func TestLabelNamerCacheDisabled(t *testing.T) { + namer := &LabelNamer{CacheDisabled: true} + + result, err := namer.Build("http.method") + if err != nil { + t.Fatal(err) + } + if result != "http_method" { + t.Errorf("expected http_method, got %s", result) + } + + result2, err := namer.Build("http.method") + if err != nil { + t.Fatal(err) + } + if result2 != "http_method" { + t.Errorf("expected http_method, got %s", result2) + } +} + +func TestLabelNamerCacheMemorySafety(t *testing.T) { + // Create a label that doesn't need transformation + label := "already_valid_label" + namer := &LabelNamer{CacheDisabled: false} + + result, err := namer.Build(label) + if err != nil { + t.Fatal(err) + } + if result != label { + t.Errorf("expected %s, got %s", label, result) + } +} + +func TestLabelNamerCacheEnabledDefault(t *testing.T) { + // Default LabelNamer{} should have cache enabled + namer := &LabelNamer{} + + result1, err := namer.Build("http.method") + if err != nil { + t.Fatal(err) + } + if result1 != "http_method" { + t.Errorf("expected http_method, got %s", result1) + } + + // Second call should hit cache + result2, err := namer.Build("http.method") + if err != nil { + t.Fatal(err) + } + if result2 != "http_method" { + t.Errorf("expected http_method, got %s", result2) + } +}