Cap the purpose meter tag to prevent unbounded cardinality - #113
Merged
Conversation
Every meter the store registers is tagged with the stream's context and
purpose. Purpose is documented as an entity id -- "e.g. customer ID, order
number" -- and half the examples in the repository are
forContext("customer").withPurpose("123"). A Micrometer registry never
evicts a meter, so used that way the meters grow with every entity the
process has ever seen and nothing reclaims them; dropping the stream handle,
which is the per-operation usage the docs recommend, releases nothing.
Measured per distinct purpose on an in-memory store with two event types:
15 meters (+2 per further event type), ~5.5KB of heap, 18 Prometheus series
and ~2.4KB of scrape body. At 10.000 purposes that is 150.000 meters, 53MB
and a 23MB scrape; 100.000 extrapolates to ~550MB and 1.8M series. Nothing
fails, which is why it went unnoticed -- the numbers stay correct and the
process just gets heavier for as long as it runs.
A store now tags the first MeterOptions.maxPurposeTagValues() distinct
purposes it sees (default 1000) and reports the rest as "_other", logging one
WARN naming the purpose that tripped it. Below the cap nothing changes, which
is the case where the breakdown is worth having; above it the meters stay
flat and the events are still counted, pooled. Re-measured at 10.000
purposes: 15.015 meters instead of 150.000, and a 2.3MB scrape instead of
23MB.
Admission is first-come-first-served and permanent, so a series a dashboard
is built on does not disappear when traffic widens. Rejected purposes are
deliberately not remembered -- memoising them would cost exactly the
cardinality being avoided -- and the slot is claimed with a CAS rather than a
size() check, which under concurrent first use of distinct purposes would
overshoot.
The cap is applied where the tag value is chosen, so it bounds the per-stream
meters, the eventtype cross product on query.event/append.event, and the
store's map of append.position gauge state in one place. That last one is why
this belongs in the library rather than in a MeterFilter the caller writes: a
filter runs at registration and the map is keyed on the tags the store asked
for, so with MeterFilter.denyNameStartsWith("sliceworkz") a registry holding
zero meters still left the store growing by ~730 bytes per purpose.
No API change for existing callers. The two-argument factory methods and
constructors apply MeterOptions.defaults(), and the new three-argument
factory method is a default method delegating to the old one, so a factory
implementation written before this still compiles. MeterOptions is also
reachable from the three storage builders' buildStore() via .meterOptions().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wmQNmjGJvQXnoFFPDReQk
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a configurable cap on the number of distinct
purposetag values that event store meters can report, preventing unbounded metric cardinality growth when purpose is used as an entity identifier (e.g., customer ID).Problem
Every meter the store registers is tagged with
contextandpurpose. When purpose is used as documented in examples —forContext("customer").withPurpose("123")— it takes one value per entity. Since Micrometer never evicts meters, the cost grows with every distinct purpose the process has ever seen:At 100,000 customers this extrapolates to ~550 MB of heap, 1.8M series, and a 234 MB scrape — nothing fails, but the process gets heavier for as long as it runs.
Solution
Introduces
MeterOptionsto cap how many distinct purposes get their own meter tag value before the rest are pooled under_other:MeterOptions)_otherwith a one-time warning log_otherseriespurposeTagValueFor) so it bounds everything downstream: per-stream meters, theeventtypecross product, and the store's internal gauge state mapKey Changes
New
MeterOptionsclass (sliceworkz-eventstore-api):withMaxPurposeTagValues(int)— cap at a specific numberwithoutPurposeBreakdown()— pool every purpose (cap = 0)withUnlimitedPurposeTagValues()— no cap (for low-cardinality purposes by construction)defaults()— returns cap of 1000Updated
EventStoreImpl(sliceworkz-eventstore-impl):MeterOptionsMeterOptions.defaults()purposeTagValueFor(String)method implements the capping logic with CAS-based admissionConcurrentHashMapand count inAtomicIntegerUpdated
EventStoreFactory(sliceworkz-eventstore-api):eventStore()method acceptingMeterOptionsUpdated storage builders (in-memory, PostgreSQL):
meterOptions()builder methodbuildStore()which creates theEventStoreImplComprehensive test suite (
MeterPurposeCardinalityTest):_otherwithoutPurposeBreakdown()behaviorDocumentation (
CLAUDE.md):purpose" sectionImplementation Details
https://claude.ai/code/session_019wmQNmjGJvQXnoFFPDReQk