Skip to content

Add opt-in Vec TTL via MetricVec and TTLRegistry#1989

Open
jpinsonneau wants to merge 12 commits into
prometheus:mainfrom
jpinsonneau:ttl-registry
Open

Add opt-in Vec TTL via MetricVec and TTLRegistry#1989
jpinsonneau wants to merge 12 commits into
prometheus:mainfrom
jpinsonneau:ttl-registry

Conversation

@jpinsonneau

@jpinsonneau jpinsonneau commented Apr 23, 2026

Copy link
Copy Markdown

Summary

  • Opt-in Vec TTL via *VecOpts.TTL (Counter/Gauge/Histogram/Summary + MetricVecOpts)
  • Decorator wrappers refresh lastAccessed on cached child mutators
  • Unified metricMap (no TTLRegistry, no duplicated ttlMetricMap)
  • Registry.Gather runs CleanupExpired only for TTL-enabled collectors
  • Documented orphan-after-expiry (same as Delete); no background cleaner

Relates to #1983
Inspired by https://gist.github.com/bwplotka/5c4d8fc904398909d7c4dd10122230f1

Signed-off-by: Julien Pinsonneau jpinsonn@redhat.com
Made-with: Cursor

Introduce NewMetricVecWithTTL and per-child lastAccessed tracking so
stale label sets can be omitted from Collect and removed with
CleanupExpired. Store vec children as pointers to satisfy atomic
copying rules.

Keep default NewCounterVec/NewGaugeVec/NewHistogramVec on NewMetricVec
without TTL for backwards compatibility. Opt-in TTLRegistry wraps a
dedicated Registry, exposes New*Vec constructors with a fixed ttl, and
runs CleanupExpired before each Gather.

Relates to prometheus#1983

Signed-off-by: Julien Pinsonneau <jpinsonn@redhat.com>
Made-with: Cursor

@kakkoyun kakkoyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. This is close to the finish line.

Please see my comments.

Comment thread prometheus/ttl_registry_test.go Outdated
Comment thread prometheus/vec_test.go
Comment thread prometheus/vec.go
Move TTL logic to ttlMetricMap so metricMap stays unchanged for
default Vecs. Wrap TTL tests with synctest instead of real sleeps.

Signed-off-by: Julien Pinsonneau <jpinsonn@redhat.com>
Made-with: Cursor

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an opt-in TTL mechanism for *Vec metrics to automatically omit and optionally delete stale label sets, aimed at reducing unbounded growth for high-cardinality / ephemeral labels while preserving backwards compatibility for existing New*Vec constructors.

Changes:

  • Add NewMetricVecWithTTL plus CleanupExpired and per-child lastAccessed tracking to support TTL-based expiration.
  • Add TTLRegistry, a dedicated registry with New*Vec constructors that enable TTL and run CleanupExpired before each Gather.
  • Refactor CounterVec, GaugeVec, and HistogramVec constructors to share internal new*VecWithTTL helpers, and add test coverage for TTL behavior.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
prometheus/vec.go Adds TTL-enabled MetricVec construction, routes core vec operations through TTL map when enabled, and exposes CleanupExpired.
prometheus/ttl_vec.go Implements TTL-backed metric storage with per-child lastAccessed, TTL-aware Collect, and cleanupExpired.
prometheus/ttl_registry.go Introduces TTLRegistry that auto-registers TTL vecs and runs cleanup before Gather.
prometheus/counter.go Refactors vec creation into newCounterVecWithTTL and wires TTL registry support.
prometheus/gauge.go Refactors vec creation into newGaugeVecWithTTL and wires TTL registry support.
prometheus/histogram.go Refactors vec creation into newHistogramVecWithTTL and wires TTL registry support.
prometheus/vec_test.go Adds TTL behavior tests for Counter/Gauge/Histogram vecs and helper for counting collected metrics.
prometheus/ttl_registry_test.go Adds tests ensuring NewTTLRegistry validation and that Gather triggers cleanup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread prometheus/vec.go Outdated
Comment thread prometheus/ttl_vec.go Outdated
Comment thread prometheus/ttl_registry.go Outdated
jpinsonneau and others added 3 commits May 19, 2026 14:52
Handle ttl<=0 in NewMetricVecWithTTL, nil slice tails in
cleanupExpired for GC, and untrack Vecs on TTLRegistry.Unregister.

Signed-off-by: Julien Pinsonneau <jpinsonn@redhat.com>
Made-with: Cursor
@jpinsonneau

Copy link
Copy Markdown
Author

@kakkoyun any update here ?

@kakkoyun kakkoyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

I leave the decision to move forward with it to @bwplotka. He is the release shepherd for the next release.

@bwplotka bwplotka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this!

I hate to block it but this creates tons of duplicated code that will be hard to maintain. Added comments inline. Especially registry API here is not ideal - registries in client_golang are for registering metrics not creating them.

I tried to play with LLM to play with alternative implementations e.g. https://gist.github.com/bwplotka/5c4d8fc904398909d7c4dd10122230f1 - this is not ideal but perhaps more acceptable.

Notably we could decorate/wrap gauge/counters etc with TTL logic to support a common "cached" metric refs flow. This also does not impact default path which is what @kakkoyun aimed for.

I am also worried about orphaned refs here, common with TTL. While this problem exists with Delete API it's more explicit to users when delete is invoked (user has to do it). Here TTL can kill the metrics any time. LLM gave some ideas to recover but it's a hot path (Inc).

There is also aspect of releasing memory. If someone has TTL to their metrics and expect it to be cleaned, but no-one scrapes then we have leak too. Have we considered time-based TTL cleanup?

Comment thread prometheus/counter.go Outdated

// newCounterVecWithTTL creates a CounterVec. ttl must be >= 0; ttl == 0 disables
// TTL behavior (identical to NewMetricVec).
func newCounterVecWithTTL(opts CounterVecOpts, ttl time.Duration) *CounterVec {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure this is what we want? How this will scale when we will have another option, newCounterVecWithTTLWithX?

If we have opts, why not using it with TTL for this purpose? Would it make sense to add ttl for those cases?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Dropped the *WithTTL helpers.

TTL now lives on CounterVecOpts / GaugeVecOpts / HistogramVecOpts / SummaryVecOpts / MetricVecOpts, e.g.:

  prometheus.V2.NewCounterVec(prometheus.CounterVecOpts{
      CounterOpts:    ...,
      VariableLabels: ...,
      TTL:            time.Minute,
  })

See 8d33a81 (and SummaryVec in 649400b).

Comment thread prometheus/vec.go Outdated
// configured TTL. It returns the number of children removed. If TTL is not
// configured (zero), this is a no-op and returns 0.
func (m *MetricVec) CleanupExpired() int {
if m.ttlMap == nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like an anti-pattern. We have a single type, but we workaround it by having a completely different data-structure inside to mask completely different, mostly duplicated code path (ttlMap vs map). 🤔

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Removed the parallel ttlMetricMap path.

TTL is a field on the existing metricMap; Collect skips expired ttlMetric children and CleanupExpired reclaims them. Default TTL == 0 keeps the previous Vec path (no wrapper, no expiration work).

See 49d10c3 (drop ttl_vec / TTLRegistry) and f366f3e (unified metricMap + MetricVecOpts).

Comment thread prometheus/ttl_registry.go Outdated
//
// Default prometheus.NewRegistry, NewCounterVec, and Opts are unchanged; use
// TTLRegistry only when you explicitly opt in to Vec TTL.
type TTLRegistry struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registry is for registering metrics and then gathering registered ones. This API breaks this by having a registry with tons of New* constructors. Are we sure this will not confuse users?

LLM review also captures this well in this point: https://gist.github.com/bwplotka/694d6c4a9be64e629d5c7b2f3541319b#a-coupling-constructors-to-registry

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's indeed also false impression that MustRegister would suddenly start TTL tracking: https://gist.github.com/bwplotka/694d6c4a9be64e629d5c7b2f3541319b#b-incomplete--error-prone-registration-surface

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Removed TTLRegistry entirely.

Construction stays on V2.New*Vec(...Opts{TTL: ...}); registration/gathering stay on a normal Registry.

See 49d10c3.

Comment thread prometheus/vec_test.go
Help: "test",
}, []string{"status"})

vec.WithLabelValues("ok").Observe(0.5)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A common approach is to cache vec.WithLabelValues("xyz") and do cachedVec.Observe(xyz). In the current code we don't

If we want TTL to work correctly, it Observe and Add must update last access time, otherwise this feature won't work for typical cases.

See: https://gist.github.com/bwplotka/694d6c4a9be64e629d5c7b2f3541319b#2-correctness-issue-cached-child-metrics-cachedvecobservexyz

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although this is already the case DeleteSeries - it must be documented though.

@jpinsonneau jpinsonneau Jul 21, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed via decorator wrappers (ttlCounter / ttlGauge / ttlHistogram / ttlSummary): mutating methods refresh lastAccessed.

Added TestTTLCachedChildKeepsAlive (and Summary coverage) for the cached-child hot path.

Active Observe/Add/Set on a cached child keeps the series alive, so TTL no longer deletes a hot cached handle out from under the caller.

Idle/orphaned handles after CleanupExpired still detach (same class of issue as Delete); documented and covered by TestTTLOrphanedCachedHandleAfterCleanup.

See 8d33a81 and 649400b.

Comment thread prometheus/ttl_registry.go Outdated
@@ -0,0 +1,165 @@
// Copyright 2014 The Prometheus Authors

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2014?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, bad copy paste. The new ttl.go uses the proper copyright header 👍

Registries in client_golang are for Register/Gather, not metric
construction. Drop TTLRegistry and the parallel ttl_vec code path so a
follow-up can reintroduce TTL via Opts without coupling constructors to
a registry.

Addresses review feedback on TTLRegistry API and code duplication.

Signed-off-by: Julien Pinsonneau <jpinsonn@redhat.com>
Made-with: Cursor
Replace NewMetricVecWithTTL / dual ttlMap branching with MetricVecOpts.TTL
and a single metricMap path. Collect skips expired ttlMetric children;
CleanupExpired reclaims them. Default TTL of 0 keeps the hot path free of
expiration work.

Addresses review feedback on new*WithTTL helpers and the ttlMap anti-pattern.

Signed-off-by: Julien Pinsonneau <jpinsonn@redhat.com>
Made-with: Cursor
Wrap Counter/Gauge/Histogram children when *VecOpts.TTL > 0 so Inc, Add,
Set, Observe (and exemplar variants) update lastAccessed. Typical
cachedObs := vec.WithLabelValues(...); cachedObs.Observe(x) flows keep
series alive without re-hashing labels.

Also plumb TTL through CounterVecOpts, GaugeVecOpts, and HistogramVecOpts
instead of separate *WithTTL constructors.

Addresses review feedback on cached child correctness and Opts-based
configuration.

Signed-off-by: Julien Pinsonneau <jpinsonn@redhat.com>
Made-with: Cursor
Call CleanupExpired on collectors that implement ExpiredCleaner before
Collect so scrapes reclaim memory. Document that there is no background
TTL goroutine: without Gather (or an explicit CleanupExpired), expired
children remain allocated. Document orphaned cached handles after TTL
the same way Delete already does.

Addresses review feedback on no-scrape leaks, Gather-time cleanup, and
orphaned refs documentation. Copyright on new ttl.go is 2026.

Signed-off-by: Julien Pinsonneau <jpinsonn@redhat.com>
Made-with: Cursor
Only run CleanupExpired during Gather when ttlEnabled is set, panic if a
custom MetricVec with TTL returns a non-TTL Metric, add SummaryVec TTL
wrappers, and document wrapper/allocation trade-offs. Tests cover
no-wrapper for TTL==0, orphaned cached handles, custom-metric panic,
SummaryVec, and Gather skipping non-TTL collectors.

Signed-off-by: Julien Pinsonneau <jpinsonn@redhat.com>
Made-with: Cursor
@jpinsonneau

Copy link
Copy Markdown
Author

Addressed feedback and refactored the PR. See PR summary. PTAL

@jpinsonneau
jpinsonneau requested review from bwplotka and kakkoyun July 21, 2026 09:54
Drop year from ttl.go copyright (repo policy for 2026+), use promoted
metricMap selectors for staticcheck QF1008, and gofumpt vec_test helpers.

Signed-off-by: Julien Pinsonneau <jpinsonn@redhat.com>
Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants