Enforce last-value-wins semantics in AttributesMap without performance regression#8548
Enforce last-value-wins semantics in AttributesMap without performance regression#8548EvgeniiR wants to merge 6 commits into
Conversation
…ixes open-telemetry#7897) AttributesMap previously extended HashMap<AttributeKey<?>, Object>, where AttributeKey.equals() includes the AttributeType. This caused attributes with the same string name but different types to coexist as separate entries, violating the OTel spec last-value-wins rule. Replace the HashMap backing with LinkedHashMap<String, AttributeEntry> keyed by raw attribute name. Overwrites with a different type now update the existing entry in place, so size() stays correct and capacity limits are not consumed. Also eliminates the double hash-probe in put() (containsKey + get → single get).
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8548 +/- ##
============================================
+ Coverage 90.96% 90.99% +0.03%
- Complexity 10206 10227 +21
============================================
Files 1013 1013
Lines 27166 27227 +61
Branches 3182 3190 +8
============================================
+ Hits 24712 24776 +64
+ Misses 1730 1729 -1
+ Partials 724 722 -2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This PR also includes benchmarks for individual read/write operations (uniqueKeys, getHit, getTypeMiss, forEachAll). The project already has More benchmarksAttributesMapBenchmark — write (avg ns/op, lower is better)
AttributesMapBenchmark — read (avg ns/op, lower is better)
FillSpanBenchmark (ops/ms, higher is better)
SpanRecordBenchmark (ops/s, higher is better)
|
…exOutOfBounds` if someone put hundreds of millions elements into AttributesMap
Can you provide a link the portion of the spec you're referring to? Thanks. |
|
@jack-berg Hi! I'm referring to this part of spec: https://opentelemetry.io/docs/specs/otel/trace/api/#set-attributes
I'm not sure if you're asking because the PR description doesn't provide enough context, or if you think I might be misinterpreting the spec and solving a problem that doesn't actually exist. Happy to clarify either way. I originally found this through the linked issue. After seeing unfinished implementation attempt and corresponding discussion, I decided to try and finish it. Happy to hear your thoughts, thanks! |
The spec is big and occasionally contradictory. Always good to have a reference!
Missing the linked issue |
|
Hey so I've been thinking about this. AttributesMap exists because the default implementation of Attributes / AttributesBuilder is not limits aware. I think that the fact that its backed by HashMap is a function of convenience: we need a simple implementation that we can apply limits too as its being built up. I think performance is important, and a Map based implementation also has better put performance than the default array based implementations ImmutableKeyValuePairs / ArrayBackedAttributesBuilder. But I think this is coincidence, since I've never heard us telling people "prefer using Span.setAttribute because it uses a more performant map based implementation". I've never loved the fact that AttributesMap exists. I'd rather have one implementation. And I think your changes to AttributesMap reinforces this because while it still tries to do some map things for performance, other parts of it are starting to look more like ImmutableKeyValuePairs / ArrayBackedAttributesBuilder. And so its got me thinking about whether we can evolve ImmutableKeyValuePairs / ArrayBackedAttributesBuilder to meet the limits requirements and rip out AttributesMap altogether. I've got two prototypes on how this could work, besides yours which I'll call "Approach 1":
Relative strengths / weaknesses of the different approaches:
Currently I lean towards Approach 3. I recognize that it probably seems somewhat orthogonal to the task you set out to solve. Like, isn't extending Attributes / AttributesBuilder a different scope / goal than making the existing SdkSpan, SdkLogRecordBuilder follow the spec W.R.T. "last write wins"? And maybe we do end up treating them separately. But the appeal of maintaining a separate Attributes implementation in AttributesMap goes away when its no longer a thin wrapper over HashMap, but rather a dedicated data structure with various low level nuances. |
|
@open-telemetry/java-approvers PTAL at my message above and let me know if you have thoughts. |
Fixes the Issue #7897
TLDR; Current AttributesMap does not fully compy with that part of spec:
Problem
AttributesMapextendedHashMap<AttributeKey<?>, Object>, usingAttributeKeyas the mapkey. Because
AttributeKey.equals()includes the attribute type, two attributes with the samename but different types (e.g.
stringKey("http.method")andlongKey("http.method")) werestored as separate entries.
This violated the OpenTelemetry specification, which requires that
attribute name alone determines identity — last write wins regardless of type.
Solution
Correctness fix
Replace the
HashMap<AttributeKey<?>, Object>backing store with a string-keyed map so thatput("http.method", String)followed byput("http.method", Long)results in exactly one entry(the Long value), consuming only one capacity slot.
The first implementations were based on HashMap/LinkedHashMap (2nd performed better once forEach was included in the benchmarks), but they introduced another issue — the fixed
AttributesMapperformed ~40-80% worse than baseline.Therefore, I started looking for a better solution that would preserve the required last-value-wins semantics without introducing a performance regression.
LinkedHashMap implementation can be observed in the previous commit — https://github.com/EvgeniiR/opentelemetry-java/blob/d7df58af76e693aa1fe897d2757e2bdb50ab9798/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributesMap.java
Final solution is described below.
Performance optimization
Instead of
LinkedHashMap<String, AttributeEntry>, use parallel arrays with an open-addressingint[]hash table (linear probing, load factor ≤ 0.5):forEachbecomes a tight sequential array loop with no pointer chasing, directly benefiting the export.Benchmark results (avgt ns/op, lower is better)
putThenForEach — N unique puts + 1 forEach (dominant production path)
* This is a worst-case for the new implementation. Performance for spans with 16–32 attributes could be improved by raising the initial array size from 16 to 32, at the cost of extra memory per map. I'm not sure where the right tradeoff sits. Currently our implementation uses less memory than the baseline for most cases:
AttributesMapBenchmark — putThenForEach memory allocation (gc.alloc.rate.norm, B/op, lower is better)
PA matches the baseline at n=4 and n=16 while being spec-correct. Given that, I think that further performance optimizations(besides may be changing init size) are out of scope of this PR.