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 7 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #8548 +/- ##
============================================
+ Coverage 91.48% 91.51% +0.02%
- Complexity 10467 10487 +20
============================================
Files 1021 1021
Lines 27694 27755 +61
Branches 3247 3255 +8
============================================
+ Hits 25337 25401 +64
+ Misses 1615 1614 -1
+ Partials 742 740 -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)
|
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. |
|
Hi, thanks for the feedback. I definitely hadn't considered the idea of abandoning AttributesMap completely. Also I re-checked my benchmarks and there was a mistake. The AttributesMapBenchmark created the map with Approach 3 looks good to me, so I will leave the decision to the maintainers. |
|
I would love to get us to a single implementation as well. The de-duping logic in the array based approach has always been the scary bit to my avoiding use using it instead of AttributesMap. If we're ok eating that performance hit, option 3 seems fine to me (as does 2, tbh). it's all the usual tradeoff of memory vs. speed, slightly coupled with maintenance complexity, I suppose (it's more maintenance to have 2 implementations vs. just 1). I wonder if there's a clever solution to the deduping, using some sort of lightweight sketch-based approach, alongside the array to save the linear scan in most cases. Could be an interesting research project for someone with time on their hands. ;) |
Pull request dashboard statusWaiting on reviewers · refreshed 2026-08-02 21:22 UTC Review the latest changes. Status above doesn't look right?
|
|
After exploring various versions of trying to reuse ArrayBackedAttributes / ArrayBackedAttributesBuilder in both API and SDK, I think its a dead end. See #8681 description for details. I've closed #8681 and #8681 accordingly, and think we should go with some version of the strategy proposed in this PR. Basically, I think for SDK attribute requirements (iterative building with enforcement of limits), we do need a separate hash map style implementation to maintain performance. It does mean maintaining two implementations, but so be it. I still need to think through / review this PR carefully. |
…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).
…exOutOfBounds` if someone put hundreds of millions elements into AttributesMap
7140ae7 to
44f6b9c
Compare
|
I rebased onto the latest |
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.putThenForEach — LinkedHashMap fix vs 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)
The small-span case is effectively unchanged, and 16 attributes improve. The 20- and
128-attribute cases regress: the parallel-array implementation starts with arrays
for 16 entries and grows through 32 and 64 before reaching 128.
AttributesMapBenchmark — putThenForEach memory allocation (gc.alloc.rate.norm, B/op, lower is better)
A smaller initial array would improve
n=4case, but would move theresize cost to spans with 16–20 attributes.