Uh oh!
There was an error while loading. Please reload this page.
Make the TagMap Entry pathway null-tolerant - #11963
Make the TagMap Entry pathway null-tolerant#11963gh-worker-dd-mergequeue-cf854d[bot] merged 3 commits into
Conversation
create(Object)/create(CharSequence) may return null for a null or empty value, and the Entry sinks -- getAndSet(Entry) / set(EntryReader) -- treat a null Entry as a no-op, so a null/empty value flows through the Entry pathway as "no tag" without any caller guarding. create(Object) now applies the empty-CharSequence check by runtime type, so the null/empty => absent convention holds regardless of the static type at the call site. The strict (key,value) setters keep their contract -- their values are now @nonnull -- so null tolerance is scoped to the Entry pathway. The annotations make the split self-describing. Fixes a latent NPE by construction: RemoteHostnameAdder sets create(TRACER_HOST, hostname) guarding only null, not empty, so an empty hostname previously NPE'd on set(null). Caller-side guard cleanup (incl. the redundant #11958 guard) is left to a follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🎯 Code Coverage (details) 🔗 Commit SHA: c451336 | Docs | Datadog PR Page | Give us feedback! |
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
A tag has no valid null key, so put/set/getAndSet/create now take @nonnull tag. This completes the null contract alongside the value/Entry side: keys are strict (null = a bug), values/Entries on the Entry pathway are tolerant (null = no tag). Scoped to the write/create surface; read/lookup keys (getString, remove, getXxxOrDefault) are left for a possible follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
More details
The null guard in getAndSet(Entry) is placed before checkWriteAccess(), which means a null entry on a frozen map silently returns null instead of throwing an AccessException — this is intentional and documented. All new and existing tests pass (4 + 181 + 112 = 297 tests); adversarial coverage confirmed the empty-StringBuilder-as-Object path, frozen-map no-op contract, and no-existing-tag-removal invariant all hold correctly.
📊 Validated against 20 scenarios · Open Bits AI session
🤖 Datadog Autotest · Commit fc7bc19 · What is Autotest? · Any feedback? Reach out in #autotest
The interface -> final-class fold removed the TagMap interface decls, which carried the @Nullable/@nonnull param annotations from #11963. Re-home them onto the now-concrete methods: @nonnull tag keys and strict-setter values, @nullable on the set(EntryReader)/getAndSet(Entry) sinks (+ the getAndSet contract javadoc). The null-tolerance behavior was already preserved by the fold; this restores the self-describing contract on the write surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/merge |
View all feedbacks in Devflow UI.
The expected merge time in
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
The interface -> final-class fold removed the TagMap interface decls, which carried the @Nullable/@nonnull param annotations from #11963. Re-home them onto the now-concrete methods: @nonnull tag keys and strict-setter values, @nullable on the set(EntryReader)/getAndSet(Entry) sinks (+ the getAndSet contract javadoc). The null-tolerance behavior was already preserved by the fold; this restores the self-describing contract on the write surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold OptimizedTagMap into a final class TagMap (drop the interface) TagMap was an interface with a single implementation, OptimizedTagMap. The split was vestigial scaffolding from when a second (HashMap-backed) impl existed; with one impl it is false generalization. Collapse them into one `public final class TagMap`: - The interface's abstract method declarations are removed; OptimizedTagMap's bodies become TagMap's methods. - Nested types that were implicitly `public static` in the interface (EntryChange, EntryRemoval, EntryReader, Entry, Ledger) are now written out explicitly as `public static`. - Static factories (create/fromMap/ledger/...) and the EMPTY constant become explicit `public static` members; the EmptyHolder lazy-init note is updated now that there is no interface<->impl class-init cycle. - putAll(TagMap) loses its `instanceof` dispatch (always true once there is one class) and calls the fast path directly. No behavior change; motivation is code simplicity, not performance (a single final class is monomorphic by construction, but CHA already devirtualized the sole impl). Public API is preserved, so callers are unchanged; the 3 tests that referenced OptimizedTagMap now reference TagMap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Drop EmptyHolder, make factories final, simplify test-helper instanceof Post-fold tidy, all TagMap-scoped: - Remove EmptyHolder: with one class there is no interface<->impl class-init cycle to break, and the private constructor reads no statics, so EMPTY is a direct `new TagMap(new Object[1], 0)` initializer. - Static factories (create/fromMap/ledger/...) are now `public static final` (not expressible on the old interface). - assertSize/assertNotEmpty/assertEmpty/checkIntegrity test helpers dropped their now-always-true `instanceof TagMap` guard + redundant cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Restore null-contract annotations lost when folding away the interface The interface -> final-class fold removed the TagMap interface decls, which carried the @Nullable/@nonnull param annotations from #11963. Re-home them onto the now-concrete methods: @nonnull tag keys and strict-setter values, @nullable on the set(EntryReader)/getAndSet(Entry) sinks (+ the getAndSet contract javadoc). The null-tolerance behavior was already preserved by the fold; this restores the self-describing contract on the write surface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Mark fromMap/fromMapImmutable map param @nonnull Per review: these factories NPE on a null map (map.size()/putAll), so the input is non-null by contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hase 1a) (#11789) Add TagMap read-through support (level-split phase 1 mechanism) A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Mark parent field @VisibleForTesting and size the tombstone HashSet small Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Collapse redundant isDefinitelyEmpty() call in isEmpty() A live read-through parent is never definitely-empty: createFromParent drops an empty parent, copy() only forwards an already-vetted parent, and the parent is frozen so it can't become empty. So the size==0 / no-tombstones branch of isEmpty() always returned false via the parent chain walk — collapse it to a documented `return false`. Addresses mcculls' review comment on #11789. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Merge branch 'master' into dougqh/tagmap-read-through Merge branch 'master' into dougqh/tagmap-read-through Merge branch 'master' into dougqh/tagmap-read-through Merge branch 'master' into dougqh/tagmap-read-through Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
…plit) (phase 1a) (#11932) Add TagMap read-through support (level-split phase 1 mechanism) A fresh, mutable TagMap can read through to a frozen parent on local misses, so a span can layer its own tags over a shared, immutable set (e.g. merged tracer tags) without copying them. - createFromParent(parent): the only way to attach a parent; the parent must be frozen and is fixed at construction (no re-parenting), so read-through can treat it as stable. Single-parent by design in phase 1. - Reads resolve local-first, then the parent; a local entry shadows the parent's (local-wins). Removing a parent key locally records a lazy tombstone (removedFromParent) so it stops reading through; the tombstone set is null until first needed, keeping the hot paths untouched. - size()/isEmpty() are exact (Map contract) and resolve the parent; isDefinitelyEmpty()/estimateSize() are the cheap conservative variants for the hot path. copy() preserves the parent and tombstones; forEach walks local then parent. Built on the folded final-class TagMap (#11967); composes cleanly with the null-tolerant Entry pathway (#11963). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Wire mergedTracerTags as a read-through parent at span build (level-split phase 1) Attach the trace's merged tracer tags to each span's TagMap as a frozen read-through parent (via TagMap.createFromParent) at span construction, instead of copying them into every span. The span sees the shared tags on read and only stores its own local tags, so the common trace-level bundle is held once per trace rather than duplicated per span. - CoreTracer builds the frozen merged-tracer-tags parent once; config version is kept out of that bundle. - DDSpanContext attaches the parent at construction (fixed, no re-parenting). - Adds TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc). Stacked on the read-through mechanism (#11789), which builds on the folded final-class TagMap (#11967). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Mark parent field @VisibleForTesting and size the tombstone HashSet small Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Collapse redundant isDefinitelyEmpty() call in isEmpty() A live read-through parent is never definitely-empty: createFromParent drops an empty parent, copy() only forwards an already-vetted parent, and the parent is frozen so it can't become empty. So the size==0 / no-tombstones branch of isEmpty() always returned false via the parent chain walk — collapse it to a documented `return false`. Addresses mcculls' review comment on #11789. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Merge branch 'dougqh/tagmap-read-through' into dougqh/tagmap-read-through-consumer Merge branch 'master' into dougqh/tagmap-read-through Merge branch 'master' into dougqh/tagmap-read-through Merge branch 'dougqh/tagmap-read-through' into dougqh/tagmap-read-through-consumer Merge branch 'master' into dougqh/tagmap-read-through Merge branch 'dougqh/tagmap-read-through' into dougqh/tagmap-read-through-consumer Make TagMap fillMap/fillStringMap read-through aware Both bulk-fill helpers walked only this.buckets, so a parent-backed TagMap materialized through them dropped tags visible only via the parent chain and ignored shadowing/tombstones -- unlike putAll, forEach, and iteration, which already honor the read-through union. Branch to the visible-union walk (forEach) when a parent is attached, mirroring putAllOptimizedMap; keep the untouched local-only loop for the common no-parent case so it pays zero overhead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Drop observationally-empty read-through parents (exact isEmpty) createFromParent used isDefinitelyEmpty(), which only checks each level's local size and ignores shadowing/tombstones. With multi-level read-through, a frozen intermediate can be observationally empty (no local entries, every inherited key tombstoned) yet report isDefinitelyEmpty() == false because a farther ancestor still holds entries. Attaching such a parent then let a child take isEmpty()'s no-tombstone fast path and return false while size(), lookup, and iteration all report empty -- a Map-contract violation. Use exact isEmpty() so semantically empty parents are dropped, which also restores the invariant isEmpty()'s fast path relies on: an attached parent always contributes at least one visible entry. The hot path is unaffected -- isEmpty() short-circuits on size != 0, only walking tombstones in the rare all-tombstoned case this fix targets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Merge remote-tracking branch 'origin/master' into dougqh/tagmap-read-through-consumer # Conflicts: # internal-api/src/main/java/datadog/trace/api/TagMap.java # internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
What Does This Do
Makes the TagMap Entry pathway null-tolerant, so a null/empty value flows through as "no tag" without any caller guarding:
Entry.create(Object)/Entry.create(CharSequence)returnnullfor a null or empty value (@Nullable).create(Object)now applies the empty-CharSequencecheck by runtime type, so an empty String passed asObjectskips the same as via theCharSequenceoverload — behavior no longer depends on the static type at the call site.getAndSet(Entry)andset(EntryReader)— treat a null Entry as a no-op (@Nullableparams). Two guards becauseset(EntryReader)derefs.entry()before delegating.(key, value)setters keep their contract: their values (set(String, Object)/set(String, CharSequence)) are@Nonnull.tagkeys on the write/create surface (put/set/getAndSet/create) are@Nonnull— a null key is a bug, not "no tag". So null-tolerance is scoped to values and Entries on the Entry pathway; keys and strict-setter values are non-null. The annotations make the whole split self-describing.Motivation
Make handling easier - and avoid NPEs
The Entry pathway is the prebuilt/producer path (
create(...) → set(Entry)); a null Entry is the designed "nothing to add" value, mirroringDDSpanContext.setTag's null/empty ⇒ absent convention. Centralizing the tolerance in the primitive means callers needn't each remember a guard — which they don't: this fixes a latent NPE by construction inRemoteHostnameAdder, which setscreate(TRACER_HOST, hostname)guarding only null (not empty), so an empty hostname previously NPE'd onset(null).Notes
getAndSet's null check is the first statement, ahead ofcheckWriteAccess()and any field read, so it dead-code-eliminates when inlined at a provably-non-null call site (e.g.set(String,value) → getAndSet(newAnyEntry(...))) and stays only where the arg can actually be null. A null Entry is thus a complete no-op and does not assert write access.InternalTagsAdder— is a fast follow-up.@Nonnullkeys cover the write/create surface; read/lookup keys (getString,remove,getXxxOrDefault) are intentionally left unannotated here — same "null key = bug" logic, but a broader, Map-contract-adjacent sweep worth doing on its own.Test
TagMapNullToleranceTestpins the contract:create(...)→ null for null/empty (incl. empty-as-Object);set/getAndSetnull no-op; end-to-endcreate()→set()with null/empty leaves no tag. ExistingTagMapTest/TagMapEntryTestgreen (no behavior regression).tag: ai generated
tag: no release note
🤖 Generated with Claude Code