From 64c42e600aa198ab2491894121018f3c67267810 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 15 Jul 2026 14:14:27 -0400 Subject: [PATCH 1/6] 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 --- .../main/java/datadog/trace/api/TagMap.java | 451 +++++++++++-- .../trace/api/TagMapReadThroughTest.java | 603 ++++++++++++++++++ 2 files changed, 1012 insertions(+), 42 deletions(-) create mode 100644 internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index 761320a2205..e0a2f2b6f70 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -6,6 +6,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.Iterator; import java.util.Map; import java.util.NoSuchElementException; @@ -48,8 +49,7 @@ public final class TagMap implements Map, Iterable. + // reads no statics, so this is safe to build directly during TagMap's . public static final TagMap EMPTY = new TagMap(new Object[1], 0); /** Creates a new mutable TagMap that contains the contents of map */ @@ -76,6 +76,32 @@ public static final TagMap create(int size) { return new TagMap(); } + /** + * Creates a fresh, mutable TagMap that reads through to {@code parent} on local misses. The + * parent must be frozen and is fixed for the life of the returned map (no re-parenting), so + * read-through relies on a stable parent rather than an unenforced convention. Level-split phase + * 1. + * + *

Parents may themselves have parents: reads and the bulk/union views both walk the full + * ancestor chain, nearest-level-wins, so a multi-level chain (e.g. baggage over trace tags) is a + * supported layering, not just a single frozen parent. + * + *

An empty parent is dropped (treated as no parent): it contributes nothing to read through, + * and — being frozen — never will, so attaching it would only add read-through cost to every + * local miss/removal for no benefit. + */ + public static final TagMap createFromParent(TagMap parent) { + if (parent != null) { + if (!parent.frozen) { + throw new IllegalStateException("read-through parent must be frozen"); + } + if (parent.isDefinitelyEmpty()) { + parent = null; + } + } + return new TagMap(parent); + } + /** Creates a new TagMap.Ledger */ public static final Ledger ledger() { return new Ledger(); @@ -991,15 +1017,47 @@ public EntryChange next() { * However as a precaution if a BucketGroup becomes completely empty, then that BucketGroup will be * removed from the collision chain. */ + private final Object[] buckets; private int size; private boolean frozen; + /** + * Optional frozen parent for read-through. When non-null, reads that miss the local buckets fall + * through to the parent chain, nearest-level-wins (a local entry shadows the parent's, a nearer + * ancestor shadows a farther one). Parents may themselves have parents, so this is a chain (e.g. + * baggage layered over trace tags). Must be frozen when attached, so it is safely shareable. Not + * final only so {@link #clear()} can detach it (to null); it is otherwise fixed at construction + * and never re-pointed. Package-visible so same-package tests can assert attach/detach directly. + */ + TagMap parent; + + /** + * Parent keys removed locally (read-through tombstones). Lazily allocated on the first such + * removal; {@code null} both means "no tombstones" and serves as the gate that keeps the hot + * paths untouched. Only meaningful when {@link #parent} != null. A tombstone stops read-through + * fall-through for its key, so a key removed from a child no longer reads through to the parent. + * Kept off the bucket structure deliberately — it is shape-agnostic (bare-Entry vs BucketGroup) + * and rare, so it costs a lazy allocation on removal rather than complicating the hot bucket + * code. + */ + private Set removedFromParent; + public TagMap() { + this((TagMap) null); + } + + /** + * Fresh mutable map that reads through to {@code parent} (may be null). The parent is set here at + * construction and never re-pointed (only detached to null by {@link #clear()}), so read-through + * optimizations can treat it as fixed. + */ + private TagMap(TagMap parent) { // needs to be a power of 2 for bucket masking calculation to work as intended this.buckets = new Object[1 << 4]; this.size = 0; this.frozen = false; + this.parent = parent; } /** Used for inexpensive immutable */ @@ -1007,6 +1065,7 @@ private TagMap(Object[] buckets, int size) { this.buckets = buckets; this.size = size; this.frozen = true; + this.parent = null; } public boolean isOptimized() { @@ -1015,12 +1074,74 @@ public boolean isOptimized() { @Override public int size() { - return this.size; + // Exact (Map contract). Under read-through resolves the union; prefer estimateSize() for hints. + TagMap parent = this.parent; + return parent == null ? this.size : this.size + this.visibleParentCount(); + } + + /** + * Exact count of ancestor entries not shadowed by a nearer level or tombstoned (the read-through + * addition). Walks the ancestor chain iteratively, nearest-level-wins. + */ + private int visibleParentCount() { + int count = 0; + for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + if (parentBucket instanceof Entry) { + if (parentEntryVisible((Entry) parentBucket, ancestor)) count++; + } else if (parentBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) parentBucket; + curGroup != null; + curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry parentEntry = curGroup._entryAt(j); + if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) count++; + } + } + } + } + } + return count; } @Override public boolean isEmpty() { - return (this.size == 0); + // Exact (Map contract). Under read-through resolves the parent; prefer isDefinitelyEmpty(). + if (this.size != 0) { + return false; + } + TagMap parent = this.parent; + if (parent == null) { + return true; + } + if (this.removedFromParent == null) { + // no local entries and no tombstones -> empty iff the whole ancestor chain is empty (nothing + // shadows it). Ancestors are frozen (no tombstones), so exact == definite here. + return parent.isDefinitelyEmpty(); + } + // size == 0 with tombstones (rare): empty iff every visible ancestor entry is tombstoned + return this.visibleParentCount() == 0; + } + + public boolean isDefinitelyEmpty() { + // Cheap: empty iff no level in the chain holds a local entry (ignores shadowing/tombstones). + for (TagMap level = this; level != null; level = level.parent) { + if (level.size != 0) { + return false; + } + } + return true; + } + + public int estimateSize() { + // Upper bound: sum of every level's local size, ignoring read-through shadowing/removals. + int total = 0; + for (TagMap level = this; level != null; level = level.parent) { + total += level.size; + } + return total; } @Deprecated @@ -1128,26 +1249,73 @@ public Set> entrySet() { } public Entry getEntry(String tag) { - Object[] thisBuckets = this.buckets; + Entry local = this.getLocalEntry(tag); + if (local != null) { + // Local entry shadows the parent (local-wins) — unchanged hot path. + return local; + } + // Read-through: miss locally, defer to the frozen parent. Single-parent in phase 1. + // The tombstone check lives only here, on the cold miss+parent path — the hot local hit above + // never touches it. + TagMap parent = this.parent; + if (parent == null) { + return null; + } + if (this.removedFromParent != null && this.removedFromParent.contains(tag)) { + return null; // tombstoned: removed locally, do not read through + } + return parent.getEntry(tag); + } + /** Looks up an entry in this map's own buckets only — no read-through to the parent. */ + private Entry getLocalEntry(String tag) { + Object[] thisBuckets = this.buckets; int hash = TagMap.Entry._hash(tag); - int bucketIndex = hash & (thisBuckets.length - 1); + return findInBucket(thisBuckets[hash & (thisBuckets.length - 1)], hash, tag); + } - Object bucket = thisBuckets[bucketIndex]; - if (bucket == null) { - return null; - } else if (bucket instanceof Entry) { + /** + * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null). + */ + private static Entry findInBucket(Object bucket, int hash, String tag) { + if (bucket instanceof Entry) { Entry tagEntry = (Entry) bucket; - if (tagEntry.matches(tag)) return tagEntry; + return tagEntry.matches(tag) ? tagEntry : null; } else if (bucket instanceof BucketGroup) { - BucketGroup lastGroup = (BucketGroup) bucket; - - Entry tagEntry = lastGroup.findInChain(hash, tag); - return tagEntry; + return ((BucketGroup) bucket).findInChain(hash, tag); } return null; } + /** + * Whether an entry that lives at ancestor level {@code fromAncestor} is visible through this + * leaf: not shadowed and not tombstoned by any nearer level (the leaf, or a closer ancestor). + * Nearest-level-wins. This mirrors what {@link #getEntry(String)}'s recursion applies as it + * descends -- each nearer level contributes both its own local entries (shadowing) and its own + * read-through removals (tombstones). A non-leaf level can carry tombstones too: a map may remove + * an inherited key and then itself be frozen and reused as a parent. Exploits universal hashing — + * by {@code _hash} the only local entry that could shadow {@code parentEntry} at a level is in + * that level's same-index bucket, so each nearer level is probed at one bucket using {@code + * parentEntry}'s cached hash (no re-hash, no full-map probe). + */ + private boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) { + int hash = parentEntry.hash(); + String tag = parentEntry.tag; + for (TagMap nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + // tombstoned by a nearer level (its own read-through removal hides the deeper entry) + if (nearer.removedFromParent != null && nearer.removedFromParent.contains(tag)) { + return false; + } + // shadowed by a nearer level's local entry + Object[] nearerBuckets = nearer.buckets; + Object nearerBucket = nearerBuckets[hash & (nearerBuckets.length - 1)]; + if (findInBucket(nearerBucket, hash, tag) != null) { + return false; + } + } + return true; + } + @Deprecated @Override public Object put(@Nonnull String tag, Object value) { @@ -1155,40 +1323,43 @@ public Object put(@Nonnull String tag, Object value) { return entry == null ? null : entry.objectValue(); } - /** A null reader is a no-op (see the null-tolerance contract on {@link #getAndSet(Entry)}). */ + /** A null reader (or a reader with no entry) is a no-op. */ public void set(@Nullable TagMap.EntryReader newEntryReader) { if (newEntryReader == null) { return; } - this.getAndSet(newEntryReader.entry()); + Entry entry = newEntryReader.entry(); + if (entry != null) { + this.putEntry(entry); + } } public void set(@Nonnull String tag, @Nonnull Object value) { - this.getAndSet(Entry.newAnyEntry(tag, value)); + this.putEntry(Entry.newAnyEntry(tag, value)); } public void set(@Nonnull String tag, @Nonnull CharSequence value) { - this.getAndSet(Entry.newObjectEntry(tag, value)); + this.putEntry(Entry.newObjectEntry(tag, value)); } public void set(@Nonnull String tag, boolean value) { - this.getAndSet(Entry.newBooleanEntry(tag, value)); + this.putEntry(Entry.newBooleanEntry(tag, value)); } public void set(@Nonnull String tag, int value) { - this.getAndSet(Entry.newIntEntry(tag, value)); + this.putEntry(Entry.newIntEntry(tag, value)); } public void set(@Nonnull String tag, long value) { - this.getAndSet(Entry.newLongEntry(tag, value)); + this.putEntry(Entry.newLongEntry(tag, value)); } public void set(@Nonnull String tag, float value) { - this.getAndSet(Entry.newFloatEntry(tag, value)); + this.putEntry(Entry.newFloatEntry(tag, value)); } public void set(@Nonnull String tag, double value) { - this.getAndSet(Entry.newDoubleEntry(tag, value)); + this.putEntry(Entry.newDoubleEntry(tag, value)); } /** @@ -1201,9 +1372,38 @@ public Entry getAndSet(@Nullable Entry newEntry) { if (newEntry == null) { return null; } + // Capture whether the key was tombstoned BEFORE putEntry clears it: a tombstoned key had no + // visible prior value (it was removed), so getAndSet must report null rather than the parent's. + boolean wasTombstoned = + this.removedFromParent != null && this.removedFromParent.contains(newEntry.tag); + + Entry priorLocal = this.putEntry(newEntry); + if (priorLocal != null) { + return priorLocal; // replaced a local entry -> that is the prior value + } + // No local entry was replaced. The prior visible value, if any, was the parent's -- unless the + // key was tombstoned (then it was not visible). set(...) skips this via putEntry (no prior). + if (wasTombstoned || this.parent == null) { + return null; + } + return this.parent.getEntry(newEntry.tag); + } + /** + * Inserts or replaces a local entry, returning the replaced local Entry (or null if none). Does + * NOT consult the read-through parent -- the {@code set(...)} methods use this so they never pay + * for a prior-value lookup they discard; {@link #getAndSet(Entry)} layers the parent fallback on + * top. + */ + private Entry putEntry(@Nonnull Entry newEntry) { this.checkWriteAccess(); + // Re-setting a key clears any read-through tombstone for it (the new value overrides the + // removal). Gated on the lazy field, so this is a no-op for the common no-tombstone case. + if (this.removedFromParent != null) { + this.removedFromParent.remove(newEntry.tag); + } + Object[] thisBuckets = this.buckets; int newHash = newEntry.hash(); @@ -1295,10 +1495,11 @@ private void putAllUnoptimizedMap(Map that) } /** - * Similar to {@link Map#putAll(Map)} but optimized to quickly copy from one TagMap to another. + * Similar to {@link Map#putAll(Map)} but optimized to quickly copy from one TagMap to another * - *

Takes advantage of the consistent TagMap layout to quickly handle each bucket. And similar - * to {@link TagMap#getAndSet(Entry)} this method shares Entry objects from the source TagMap. + *

For optimized TagMaps, this method takes advantage of the consistent TagMap layout to + * quickly handle each bucket. And similar to {@link TagMap#getAndSet(Entry)} this method shares + * Entry objects from the source TagMap */ public void putAll(TagMap that) { this.checkWriteAccess(); @@ -1307,6 +1508,13 @@ public void putAll(TagMap that) { } private void putAllOptimizedMap(TagMap that) { + if (that.parent != null) { + // read-through source: the bucket-copy paths below only see that's local entries, so they + // would drop entries visible only through that's ancestor chain (and ignore its tombstones). + // Union-copy the full visible set instead -- still shares the source Entry objects. + that.forEach(this, (self, entry) -> self.set(entry)); + return; + } if (this.size == 0) { this.putAllIntoEmptyMap(that); } else { @@ -1506,6 +1714,32 @@ public boolean remove(String tag) { public Entry getAndRemove(String tag) { this.checkWriteAccess(); + Entry localRemoved = this.removeLocal(tag); + + TagMap parent = this.parent; + if (parent != null) { + // Read-through: if the parent still exposes this key, removing it must also hide it from + // fall-through — install a tombstone. The prior *visible* value (Map.remove contract) is the + // local entry if there was one, otherwise the parent's (which we now hide). Single-parent in + // phase 1; rare path (only when removing a parent-exposed key). + boolean alreadyTombstoned = + this.removedFromParent != null && this.removedFromParent.contains(tag); + if (!alreadyTombstoned) { + Entry parentEntry = parent.getEntry(tag); + if (parentEntry != null) { + if (this.removedFromParent == null) { + this.removedFromParent = new HashSet<>(); + } + this.removedFromParent.add(tag); + return localRemoved != null ? localRemoved : parentEntry; + } + } + } + return localRemoved; + } + + /** Removes an entry from this map's own buckets only — no parent/tombstone handling. */ + private Entry removeLocal(String tag) { Object[] thisBuckets = this.buckets; int hash = TagMap.Entry._hash(tag); @@ -1543,8 +1777,14 @@ public Entry getAndRemove(String tag) { } public TagMap copy() { - TagMap copy = new TagMap(); + // Construct with the same (frozen, shared) parent up front — the parent is fixed at + // construction. putAll then clones this map's own (local) buckets + size. The copy stays + // independently mutable (writes land on its local buckets, never the shared parent). + TagMap copy = new TagMap(this.parent); copy.putAllIntoEmptyMap(this); + if (this.removedFromParent != null) { + copy.removedFromParent = new HashSet<>(this.removedFromParent); + } return copy; } @@ -1582,6 +1822,37 @@ public void forEach(Consumer consumer) { thisGroup.forEachInChain(consumer); } } + + // read-through: parent entries not shadowed locally or tombstoned. Kept out of line so the + // common parent == null path stays byte-identical to before (small / inlinable). + if (this.parent != null) { + this.forEachParent(consumer); + } + } + + private void forEachParent(Consumer consumer) { + // Walk the ancestor chain, nearest first. Each entry is emitted once, by the nearest level that + // defines its key, when not shadowed by a nearer level and not tombstoned. + for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + if (parentBucket instanceof Entry) { + Entry parentEntry = (Entry) parentBucket; + if (parentEntryVisible(parentEntry, ancestor)) consumer.accept(parentEntry); + } else if (parentBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) parentBucket; + curGroup != null; + curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry parentEntry = curGroup._entryAt(j); + if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) + consumer.accept(parentEntry); + } + } + } + } + } } public void forEach(T thisObj, BiConsumer consumer) { @@ -1600,6 +1871,35 @@ public void forEach(T thisObj, BiConsumer con thisGroup.forEachInChain(thisObj, consumer); } } + + // read-through: parent entries not shadowed locally or tombstoned (kept out of line). + if (this.parent != null) { + this.forEachParent(thisObj, consumer); + } + } + + private void forEachParent(T thisObj, BiConsumer consumer) { + for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + if (parentBucket instanceof Entry) { + Entry parentEntry = (Entry) parentBucket; + if (parentEntryVisible(parentEntry, ancestor)) consumer.accept(thisObj, parentEntry); + } else if (parentBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) parentBucket; + curGroup != null; + curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry parentEntry = curGroup._entryAt(j); + if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) { + consumer.accept(thisObj, parentEntry); + } + } + } + } + } + } } public void forEach( @@ -1619,6 +1919,37 @@ public void forEach( thisGroup.forEachInChain(thisObj, otherObj, consumer); } } + + // read-through: parent entries not shadowed locally or tombstoned (kept out of line). + if (this.parent != null) { + this.forEachParent(thisObj, otherObj, consumer); + } + } + + private void forEachParent( + T thisObj, U otherObj, TriConsumer consumer) { + for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + if (parentBucket instanceof Entry) { + Entry parentEntry = (Entry) parentBucket; + if (parentEntryVisible(parentEntry, ancestor)) + consumer.accept(thisObj, otherObj, parentEntry); + } else if (parentBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) parentBucket; + curGroup != null; + curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry parentEntry = curGroup._entryAt(j); + if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) { + consumer.accept(thisObj, otherObj, parentEntry); + } + } + } + } + } + } } public void clear() { @@ -1626,6 +1957,11 @@ public void clear() { Arrays.fill(this.buckets, null); this.size = 0; + // clear() removes ALL mappings, including any inherited through read-through. Detaching the + // parent (rather than tombstoning every inherited key) is simpler and cheaper, and leaves an + // empty, parent-less map. Detach is one-way -- the parent is never re-pointed. + this.parent = null; + this.removedFromParent = null; } public TagMap freeze() { @@ -1683,7 +2019,9 @@ void checkIntegrity() { if (this.size != this.computeSize()) { throw new IllegalStateException("incorrect size"); } - if (this.isEmpty() != this.checkIfEmpty()) { + // Local-structure invariant: the size counter's emptiness must match the local buckets. Uses + // the local (this.size == 0), NOT isEmpty(), which under read-through resolves the parent too. + if ((this.size == 0) != this.checkIfEmpty()) { throw new IllegalStateException("incorrect empty status"); } } @@ -1801,7 +2139,12 @@ String toInternalString() { } abstract static class IteratorBase { - private final Object[] buckets; + private final TagMap map; + + // the level whose buckets are currently being walked: the leaf (map) first, then each ancestor + // in turn (read-through union). map == level means we're on the leaf's own entries. + private TagMap level; + private Object[] buckets; private Entry nextEntry; @@ -1811,18 +2154,16 @@ abstract static class IteratorBase { private int groupIndex = 0; IteratorBase(TagMap map) { + this.map = map; + this.level = map; this.buckets = map.buckets; } public final boolean hasNext() { if (this.nextEntry != null) return true; - while (this.bucketIndex < this.buckets.length) { - this.nextEntry = this.advance(); - if (this.nextEntry != null) return true; - } - - return false; + this.nextEntry = this.advance(); + return this.nextEntry != null; } final Entry nextEntryOrThrowNoSuchElement() { @@ -1850,6 +2191,32 @@ final Entry nextEntryOrNull() { } private final Entry advance() { + while (true) { + Entry tagEntry = this.rawAdvance(); + if (tagEntry != null) { + // leaf entries emit as-is; ancestor entries only if visible from the leaf -- not shadowed + // by a nearer level and not tombstoned. (parentEntryVisible walks the nearer levels.) + if (this.level == this.map || this.map.parentEntryVisible(tagEntry, this.level)) { + return tagEntry; + } + continue; // ancestor entry shadowed/tombstoned -> skip + } + + // current level exhausted; advance to the next ancestor's buckets (read-through union) + if (this.level.parent != null) { + this.level = this.level.parent; + this.buckets = this.level.buckets; + this.bucketIndex = -1; + this.group = null; + this.groupIndex = 0; + continue; + } + return null; + } + } + + /** Next raw entry in the current bucket array, ignoring shadowing/tombstones. */ + private final Entry rawAdvance() { while (this.bucketIndex < this.buckets.length) { if (this.group != null) { for (++this.groupIndex; this.groupIndex < BucketGroup.LEN; ++this.groupIndex) { @@ -2367,12 +2734,12 @@ static final class Entries extends AbstractSet> { @Override public int size() { - return this.map.computeSize(); + return this.map.size(); } @Override public boolean isEmpty() { - return this.map.checkIfEmpty(); + return this.map.isEmpty(); } @Override @@ -2392,12 +2759,12 @@ static final class Keys extends AbstractSet { @Override public int size() { - return this.map.computeSize(); + return this.map.size(); } @Override public boolean isEmpty() { - return this.map.checkIfEmpty(); + return this.map.isEmpty(); } @Override @@ -2431,12 +2798,12 @@ static final class Values extends AbstractCollection { @Override public int size() { - return this.map.computeSize(); + return this.map.size(); } @Override public boolean isEmpty() { - return this.map.checkIfEmpty(); + return this.map.isEmpty(); } @Override diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java new file mode 100644 index 00000000000..14b040a8406 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java @@ -0,0 +1,603 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * Read-through support, slice 1 (read path): a child {@link TagMap} with a frozen parent reads + * through to the parent on a local miss, while local entries shadow the parent (local-wins). + * Removal/tombstones and bulk (iteration/serialize) union come in later slices. + */ +class TagMapReadThroughTest { + + private static TagMap frozenParent() { + TagMap parent = TagMap.create(); + parent.set("a", "parent-a"); + parent.set("b", "parent-b"); + parent.freeze(); + return parent; + } + + @Test + void readsThroughToParentOnMiss() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + assertEquals("parent-a", child.getString("a")); // miss locally -> read through + assertEquals("parent-b", child.getString("b")); + assertEquals("child-c", child.getString("c")); // local + assertNull(child.getString("missing")); + assertTrue(child.containsKey("a")); + assertFalse(child.containsKey("missing")); + } + + @Test + void localEntryShadowsParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // same key as parent + + assertEquals("child-b", child.getString("b")); // local wins + assertEquals("parent-a", child.getString("a")); // parent still visible + } + + @Test + void estimateSizeIsUpperBound() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + // true union = {a, b, c} = 3; estimate over-counts the shadowed "b": local 2 + parent 2 = 4 + assertEquals(4, child.estimateSize()); + assertTrue(child.estimateSize() >= 3, "estimateSize must be an upper bound on the true size"); + } + + @Test + void emptinessSemantics() { + TagMap emptyOverEmpty = TagMap.createFromParent(TagMap.create().freeze()); + assertTrue(emptyOverEmpty.isEmpty()); + assertTrue(emptyOverEmpty.isDefinitelyEmpty()); + + TagMap emptyOverNonEmpty = TagMap.createFromParent(frozenParent()); + assertFalse(emptyOverNonEmpty.isEmpty(), "a non-empty parent makes the map non-empty"); + assertFalse(emptyOverNonEmpty.isDefinitelyEmpty()); + + assertTrue((TagMap.create()).isDefinitelyEmpty()); + } + + @Test + void parentMustBeFrozen() { + TagMap mutableParent = TagMap.create(); + assertThrows(IllegalStateException.class, () -> TagMap.createFromParent(mutableParent)); + } + + @Test + void emptyParentIsDroppedNotAttached() { + TagMap emptyFrozen = TagMap.create(); + emptyFrozen.freeze(); + + TagMap overEmpty = TagMap.createFromParent(emptyFrozen); + // an empty frozen parent contributes nothing and never will -> dropped, no read-through cost + assertNull(overEmpty.parent, "empty parent should be dropped"); + assertTrue(overEmpty.isDefinitelyEmpty()); + overEmpty.set("x", "x-val"); // still a normal mutable map + assertEquals("x-val", overEmpty.getString("x")); + + // a non-empty parent is still attached + TagMap overNonEmpty = TagMap.createFromParent(frozenParent()); + assertNotNull(overNonEmpty.parent, "non-empty parent must be attached"); + assertEquals("parent-a", overNonEmpty.getString("a")); + } + + // --- slice 2: removal / tombstones --- + + @Test + void removingParentKeyHidesItFromChildButNotFromParent() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + + assertEquals("parent-a", child.getString("a")); // visible before removal + child.remove("a"); + + assertNull(child.getString("a")); // tombstoned: no longer reads through + assertFalse(child.containsKey("a")); + assertEquals("parent-b", child.getString("b")); // other parent keys unaffected + assertEquals("parent-a", parent.getString("a")); // frozen parent untouched + } + + @Test + void removeReturnsPriorVisibleValueViaParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + + // Map.remove contract: the key was present (via read-through), so removal reports it. + assertTrue(child.remove("a"), "removing a parent-exposed key should report it was present"); + assertNull(child.getString("a")); + } + + @Test + void reSettingARemovedKeyRestoresVisibility() { + TagMap child = TagMap.createFromParent(frozenParent()); + + child.remove("a"); + assertNull(child.getString("a")); + + child.set("a", "child-a"); // re-set clears the tombstone + assertEquals("child-a", child.getString("a")); + } + + @Test + void removingAKeyThatIsBothLocalAndParentHidesBoth() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + + assertEquals("child-b", child.getString("b")); + child.remove("b"); + + assertNull(child.getString("b"), "removal must hide both the local entry and the parent's"); + assertEquals("parent-b", frozenParent().getString("b")); // parent still has it + } + + // --- slice 3a: bulk forEach union + exact size/isEmpty --- + + private static Map collect(TagMap map) { + Map out = new HashMap<>(); + map.forEach(e -> out.put(e.tag(), e.objectValue())); + return out; + } + + @Test + void forEachEmitsDedupedUnionLocalWins() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map u = collect(child); + assertEquals(3, u.size(), "union {a, b, c} with b deduped"); + assertEquals("parent-a", u.get("a")); // read-through + assertEquals("child-b", u.get("b")); // local wins (no duplicate emit) + assertEquals("child-c", u.get("c")); + } + + @Test + void forEachSkipsTombstonedParentKeys() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + child.remove("a"); // tombstone parent's "a" + + Map u = collect(child); + assertEquals(2, u.size()); + assertFalse(u.containsKey("a")); + assertEquals("parent-b", u.get("b")); + assertEquals("child-c", u.get("c")); + } + + @Test + void biConsumerForEachAlsoEmitsUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + Map out = new HashMap<>(); + child.forEach(out, (m, e) -> m.put(e.tag(), e.objectValue())); // non-capturing: alloc-free path + assertEquals(3, out.size()); + assertEquals("parent-a", out.get("a")); + assertEquals("child-c", out.get("c")); + } + + @Test + void sizeIsExactUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows + child.set("c", "child-c"); + assertEquals(3, child.size()); // {a, b, c} — b deduped, not 4 + + child.remove("a"); + assertEquals(2, child.size()); // {b, c} + } + + @Test + void isEmptyExactWhenAllParentKeysTombstonedAndNoLocal() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + assertFalse(child.isEmpty()); + + child.remove("a"); + child.remove("b"); + assertTrue(child.isEmpty(), "all parent keys tombstoned and no local entries -> empty"); + assertEquals(0, child.size()); + } + + // --- slice 3b: pull-based iterators / collection views --- + + @Test + void iteratorEmitsDedupedUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map u = new HashMap<>(); + Iterator it = child.iterator(); + while (it.hasNext()) { + TagMap.EntryReader e = it.next(); + u.put(e.tag(), e.objectValue()); + } + assertEquals(3, u.size()); + assertEquals("parent-a", u.get("a")); + assertEquals("child-b", u.get("b")); // local wins, emitted once + assertEquals("child-c", u.get("c")); + } + + @Test + void keySetReflectsUnionAndTombstones() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + Set keys = child.keySet(); + assertEquals(3, keys.size()); // a, b, c + assertTrue(keys.contains("a")); + assertTrue(keys.contains("c")); + + child.remove("a"); + assertEquals(2, child.keySet().size()); + assertFalse(child.keySet().contains("a")); + } + + @Test + void valuesAndEntrySetReflectUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + + assertEquals(2, child.entrySet().size()); // {a, b} — b deduped + assertTrue(child.values().contains("child-b")); // local-won value + assertTrue(child.values().contains("parent-a")); + assertFalse(child.values().contains("parent-b"), "shadowed parent value must not appear"); + } + + // --- slice 3c: putAll from a read-through source copies the visible union, not just locals --- + + @Test + void putAllFromReadThroughSourceCopiesFullVisibleUnion() { + TagMap source = TagMap.createFromParent(frozenParent()); // parent {a, b} + source.set("b", "child-b"); // shadows parent b + source.set("c", "child-c"); + + TagMap dest = TagMap.create(); // empty -> putAllIntoEmptyMap path + dest.putAll(source); + + // the parent-visible "a" must land too, not just source's local entries + assertEquals(3, dest.size()); + assertEquals("parent-a", dest.getString("a")); + assertEquals("child-b", dest.getString("b")); // local-won value, deduped + assertEquals("child-c", dest.getString("c")); + } + + @Test + void putAllMergeFromReadThroughSourceCopiesVisibleUnion() { + TagMap source = TagMap.createFromParent(frozenParent()); // {a, b} + + TagMap dest = TagMap.create(); + dest.set("z", "dest-z"); // dest non-empty -> putAllMerge path + + dest.putAll(source); + assertEquals(3, dest.size()); // {a, b, z} + assertEquals("parent-a", dest.getString("a")); + assertEquals("parent-b", dest.getString("b")); + assertEquals("dest-z", dest.getString("z")); + } + + @Test + void putAllFromReadThroughSourceHonorsTombstones() { + TagMap source = TagMap.createFromParent(frozenParent()); + source.remove("a"); // tombstone parent's "a" + + TagMap dest = TagMap.create(); + dest.putAll(source); + + assertEquals(1, dest.size()); + assertFalse(dest.containsKey("a"), "tombstoned key must not be copied"); + assertEquals("parent-b", dest.getString("b")); + } + + // --- slice 4: behavior-identical to a copy-down / flat map --- + + @Test + void copyIsObservationallyIdentical() { + TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + TagMap copy = child.copy(); + assertEquals(child.size(), copy.size()); + assertEquals("parent-a", copy.getString("a")); // copy still reads through + assertEquals("child-b", copy.getString("b")); + assertEquals("child-c", copy.getString("c")); + assertEquals(collect(child), collect(copy)); // same union + } + + @Test + void copyIsIndependentlyMutable() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + TagMap copy = child.copy(); + copy.set("c", "copy-c"); // mutate copy's local + copy.remove("a"); // tombstone on copy only + + assertEquals("child-c", child.getString("c"), "original unaffected by copy mutation"); + assertEquals("parent-a", child.getString("a"), "original still reads through a"); + assertEquals("copy-c", copy.getString("c")); + assertNull(copy.getString("a")); + } + + @Test + void copyPreservesTombstones() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("a"); // tombstone "a" + + TagMap copy = child.copy(); + assertNull(copy.getString("a"), "tombstone must carry into the copy"); + assertEquals("parent-b", copy.getString("b")); + } + + /** The contract that lets the consumer flip mergedTracerTags to a parent. */ + @Test + void readThroughMatchesAnEquivalentFlatMap() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); + child.set("c", "child-c"); + + TagMap flat = TagMap.create(); + flat.set("a", "parent-a"); + flat.set("b", "child-b"); + flat.set("c", "child-c"); + + assertEquals(flat.size(), child.size()); + assertEquals(collect(flat), collect(child)); + assertEquals(flat.keySet(), child.keySet()); + for (String k : new String[] {"a", "b", "c", "missing"}) { + assertEquals(flat.getString(k), child.getString(k), "mismatch for key " + k); + } + } + + @Test + void immutableCopyOfReadThroughIsFrozenAndStillReadsThrough() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + TagMap frozen = child.immutableCopy(); + assertTrue(frozen.isFrozen()); + assertEquals("parent-a", frozen.getString("a")); // union preserved + assertEquals("child-c", frozen.getString("c")); + assertThrows(IllegalStateException.class, () -> frozen.set("x", "y")); // frozen blocks writes + } + + // --- slice 5: multi-level chains (baggage-style layering over more than one frozen parent) --- + + /** + * Builds a 3-level chain leaf -> mid -> grandparent (both ancestors frozen) and returns the + * leaf. Visible union, nearest-level-wins: {a=gp-a, b=mid-b, c=leaf-c, d=mid-d, e=leaf-e}. + */ + private static TagMap threeLevelLeaf() { + TagMap grandparent = TagMap.create(); + grandparent.set("a", "gp-a"); + grandparent.set("b", "gp-b"); + grandparent.set("c", "gp-c"); + grandparent.freeze(); + + TagMap mid = TagMap.createFromParent(grandparent); + mid.set("b", "mid-b"); // shadows grandparent b + mid.set("d", "mid-d"); + mid.freeze(); + + TagMap leaf = TagMap.createFromParent(mid); + leaf.set("c", "leaf-c"); // shadows grandparent c (mid doesn't define c) + leaf.set("e", "leaf-e"); + return leaf; + } + + @Test + void getWalksTheWholeChainNearestWins() { + TagMap leaf = threeLevelLeaf(); + assertEquals("gp-a", leaf.getString("a")); // only in grandparent (two levels up) + assertEquals("mid-b", leaf.getString("b")); // mid shadows grandparent + assertEquals("leaf-c", leaf.getString("c")); // leaf shadows grandparent + assertEquals("mid-d", leaf.getString("d")); + assertEquals("leaf-e", leaf.getString("e")); + assertNull(leaf.getString("missing")); + } + + @Test + void sizeIsExactUnionAcrossChain() { + assertEquals(5, threeLevelLeaf().size()); // {a, b, c, d, e}, shadowed duplicates deduped + } + + @Test + void forEachEmitsDedupedUnionAcrossChain() { + Map u = collect(threeLevelLeaf()); + assertEquals(5, u.size()); + assertEquals("gp-a", u.get("a")); + assertEquals("mid-b", u.get("b")); // nearest ancestor wins over grandparent + assertEquals("leaf-c", u.get("c")); // leaf wins + assertEquals("mid-d", u.get("d")); + assertEquals("leaf-e", u.get("e")); + } + + @Test + void iteratorEmitsDedupedUnionAcrossChain() { + Map u = new HashMap<>(); + Iterator it = threeLevelLeaf().iterator(); + while (it.hasNext()) { + TagMap.EntryReader e = it.next(); + u.put(e.tag(), e.objectValue()); + } + assertEquals(5, u.size()); + assertEquals("gp-a", u.get("a")); + assertEquals("mid-b", u.get("b")); + assertEquals("leaf-c", u.get("c")); + } + + @Test + void keySetReflectsChainUnion() { + Set keys = threeLevelLeaf().keySet(); + assertEquals(5, keys.size()); + for (String k : new String[] {"a", "b", "c", "d", "e"}) { + assertTrue(keys.contains(k), "missing key " + k); + } + } + + @Test + void leafTombstoneHidesGrandparentOnlyKey() { + TagMap leaf = threeLevelLeaf(); + leaf.remove("a"); // "a" lives only in the grandparent, two levels up + assertNull(leaf.getString("a")); + assertFalse(leaf.containsKey("a")); + assertFalse(leaf.keySet().contains("a")); + assertEquals(4, leaf.size()); // {b, c, d, e} + } + + @Test + void intermediateAncestorTombstoneIsHonoredByBulkViews() { + // mid removes an inherited grandparent key, THEN is frozen and reused as a parent. A non-leaf + // level can therefore carry its own tombstones. Point lookups recurse through mid's tombstone + // (correct); the bulk views must agree and not re-emit the key. + TagMap grandparent = TagMap.create(); + grandparent.set("a", "gp-a"); + grandparent.set("b", "gp-b"); + grandparent.freeze(); + + TagMap mid = TagMap.createFromParent(grandparent); + mid.remove("a"); // tombstone an inherited key before freezing + mid.freeze(); + + TagMap leaf = TagMap.createFromParent(mid); + + // point lookups (recurse -> already correct) + assertNull(leaf.getString("a")); + assertFalse(leaf.containsKey("a")); + assertEquals("gp-b", leaf.getString("b")); + + // bulk views must agree: mid's tombstone hides grandparent's "a" + assertEquals(1, leaf.size()); + assertFalse(leaf.keySet().contains("a")); + + Map viaForEach = collect(leaf); + assertEquals(1, viaForEach.size()); + assertFalse(viaForEach.containsKey("a")); + assertEquals("gp-b", viaForEach.get("b")); + + Map viaIterator = new HashMap<>(); + Iterator it = leaf.iterator(); + while (it.hasNext()) { + TagMap.EntryReader e = it.next(); + viaIterator.put(e.tag(), e.objectValue()); + } + assertEquals(1, viaIterator.size()); + assertFalse(viaIterator.containsKey("a")); + } + + @Test + void chainReadThroughMatchesEquivalentFlatMap() { + TagMap leaf = threeLevelLeaf(); + + TagMap flat = TagMap.create(); + flat.set("a", "gp-a"); + flat.set("b", "mid-b"); + flat.set("c", "leaf-c"); + flat.set("d", "mid-d"); + flat.set("e", "leaf-e"); + + assertEquals(flat.size(), leaf.size()); + assertEquals(collect(flat), collect(leaf)); + assertEquals(flat.keySet(), leaf.keySet()); + for (String k : new String[] {"a", "b", "c", "d", "e", "missing"}) { + assertEquals(flat.getString(k), leaf.getString(k), "mismatch for key " + k); + } + } + + // --- slice 6: put/getAndSet report the prior visible value, including inherited --- + + @Test + void putReturnsInheritedParentValueAsPrior() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + Object prior = child.put("a", "child-a"); // "a" exists only in the parent + assertEquals("parent-a", prior, "put must report the inherited value as the previous mapping"); + assertEquals("child-a", child.getString("a")); // new value stored locally + } + + @Test + void putReturnsNullForAKeyInNeitherLocalNorParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + assertNull(child.put("brand-new", "v")); + } + + @Test + void putReturnsLocalPriorWhenShadowingParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("a", "local-a"); // local now shadows the parent + assertEquals("local-a", child.put("a", "local-a2"), "the local prior wins over the parent's"); + } + + @Test + void putAfterRemoveReportsNoPriorNotTheParentValue() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("a"); // tombstone the parent's "a": no longer visible + assertNull(child.put("a", "child-a"), "a tombstoned key had no visible prior value"); + assertEquals("child-a", child.getString("a")); + } + + @Test + void getAndSetReturnsInheritedEntryAsPrior() { + TagMap child = TagMap.createFromParent(frozenParent()); + TagMap.Entry prior = child.getAndSet("b", "child-b"); + assertEquals("parent-b", prior.objectValue()); + } + + @Test + void setDoesNotReportPriorButStillClearsTombstone() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("b"); // tombstone + child.set("b", "child-b"); // void set: no prior lookup, but must clear the tombstone + assertEquals("child-b", child.getString("b")); + } + + // --- slice 7: clear() removes inherited mappings too (detaches the parent) --- + + @Test + void clearRemovesInheritedMappingsAndDetachesParent() { + TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} + child.set("c", "child-c"); + + child.clear(); + + assertTrue(child.isEmpty(), "clear must remove local AND inherited mappings"); + assertEquals(0, child.size()); + assertNull(child.getString("a")); // inherited no longer visible + assertNull(child.getString("c")); + assertFalse(child.containsKey("a")); + } + + @Test + void clearDoesNotAffectTheFrozenParent() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + child.clear(); + assertEquals("parent-a", parent.getString("a"), "the shared frozen parent is untouched"); + } + + @Test + void putAfterClearBehavesAsAPlainMap() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.clear(); + child.set("x", "x-val"); + assertEquals(1, child.size()); + assertEquals("x-val", child.getString("x")); + assertNull(child.getString("a"), "no read-through after clear detached the parent"); + } +} From 170eedb11d3257928403f7992365103238759025 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 15 Jul 2026 14:22:44 -0400 Subject: [PATCH 2/6] 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 --- .../java/datadog/trace/core/CoreTracer.java | 40 ++++++++- .../datadog/trace/core/DDSpanContext.java | 68 ++++++++++++++- .../trace/core/WithTracerTagsVersionTest.java | 42 ++++++++++ .../util/TagMapReadThroughBenchmark.java | 84 +++++++++++++++++++ 4 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/WithTracerTagsVersionTest.java create mode 100644 internal-api/src/jmh/java/datadog/trace/util/TagMapReadThroughBenchmark.java diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index c48d8f94df6..120ba6e8df8 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -2207,18 +2207,31 @@ protected static final DDSpanContext buildSpanContext( propagationTags, tracer.profilingContextIntegration, tracer.injectBaggageAsTags, - tracer.injectLinksAsTags); + tracer.injectLinksAsTags, + mergedTracerTagsNeedsIntercept ? null : mergedTracerTags); // By setting the tags on the context we apply decorators to any tags that have been set via // the builder. This is the order that the tags were added previously, but maybe the `tags` // set in the builder should come last, so that they override other tags. - context.setAllTags(mergedTracerTags, mergedTracerTagsNeedsIntercept); + // + // mergedTracerTags is trace-level shared state and the precedence floor (everything below + // overrides it). When it carries no interceptable tags it is attached as a read-through + // PARENT at construction (shared by reference, no per-span copy). When it does need + // interception, copy its entries in (the interceptor's per-span side-effects can't be + // shared by reference). + if (mergedTracerTagsNeedsIntercept) { + context.setAllTags(mergedTracerTags, true); + } context.setAllTags(tagLedger); context.setAllTags(coreTags, coreTagsNeedsIntercept); context.setAllTags(rootSpanTags, rootSpanTagsNeedsIntercept); context.setAllTags(contextualTags); - // remove version here since will be done later on the postProcessor. - // it will allow knowing if it will be set manually or not + // Version is added later by the postProcessor (InternalTagsAdder), only if not already set + // during the request. Config version is kept out of the trace-level bundle (see + // withTracerTags), so this removal now only wipes a version set via the span builder — + // keeping + // the existing semantics where a builder-set version is replaced by the config version. Under + // read-through this is a cheap local removal (version isn't in the parent, so no tombstone). context.removeTag(Tags.VERSION); return context; } @@ -2449,6 +2462,25 @@ static TagMap withTracerTags( Map userSpanTags, Config config, TraceConfig traceConfig) { final TagMap result = TagMap.create(userSpanTags.size() + 5); result.putAll(userSpanTags); + // Version is conditionally managed by InternalTagsAdder (added only when service == DD_SERVICE + // and not set during the request), so keep it OUT of the trace-level bundle. This matters under + // read-through: the bundle becomes a shared parent, and a per-span removeTag(VERSION) on a key + // that lived in the parent would mint a per-span tombstone. With version excluded here, the + // per-span removeTag (retained, to wipe a builder-set version) is a cheap local op, never a + // tombstone. + // + // EXCEPTION: when `version` is a split-service tag, the TagInterceptor derives the service name + // from it, so it must reach the interceptor. Keeping it in the bundle forces the intercepting + // seed path (a split tag makes the bundle needsIntercept=true -> copied, not a read-through + // parent), where the retained removeTag(VERSION) still deletes only a local copy -- so the + // split + // side-effect fires and no per-span tombstone is minted either way. + // + // Cold path: withTracerTags runs at setup / config-change, not per span (mergedTracerTags is + // cached on the config snapshot), so this getSplitByTags() lookup needn't be hoisted. + if (config == null || !config.getSplitByTags().contains(Tags.VERSION)) { + result.remove(Tags.VERSION); + } if (null != config) { // static if (!config.getEnv().isEmpty()) { result.set("env", config.getEnv()); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 6120502ec09..a2d87e2c18b 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -243,7 +243,8 @@ public DDSpanContext( propagationTags, ProfilingContextIntegration.NoOp.INSTANCE, true, - true); + true, + null); } public DDSpanContext( @@ -293,9 +294,11 @@ public DDSpanContext( propagationTags, ProfilingContextIntegration.NoOp.INSTANCE, injectBaggageAsTags, - injectLinksAsTags); + injectLinksAsTags, + null); } + /** Back-compat ctor (no read-through parent); delegates with a null parent. */ public DDSpanContext( final DDTraceId traceId, final long spanId, @@ -322,6 +325,62 @@ public DDSpanContext( final ProfilingContextIntegration profilingContextIntegration, final boolean injectBaggageAsTags, final boolean injectLinksAsTags) { + this( + traceId, + spanId, + parentId, + parentServiceName, + serviceNameSource, + serviceName, + operationName, + resourceName, + samplingPriority, + origin, + baggageItems, + w3cBaggage, + errorFlag, + spanType, + tagsSize, + traceCollector, + requestContextDataAppSec, + requestContextDataIast, + CiVisibilityContextData, + pathwayContext, + disableSamplingMechanismValidation, + propagationTags, + profilingContextIntegration, + injectBaggageAsTags, + injectLinksAsTags, + null); + } + + public DDSpanContext( + final DDTraceId traceId, + final long spanId, + final long parentId, + final CharSequence parentServiceName, + final CharSequence serviceNameSource, + final String serviceName, + final CharSequence operationName, + final CharSequence resourceName, + final int samplingPriority, + final CharSequence origin, + final Map baggageItems, + final Baggage w3cBaggage, + final boolean errorFlag, + final CharSequence spanType, + final int tagsSize, + final TraceCollector traceCollector, + final Object requestContextDataAppSec, + final Object requestContextDataIast, + final Object CiVisibilityContextData, + final PathwayContext pathwayContext, + final boolean disableSamplingMechanismValidation, + final PropagationTags propagationTags, + final ProfilingContextIntegration profilingContextIntegration, + final boolean injectBaggageAsTags, + final boolean injectLinksAsTags, + final TagMap readThroughParent) { assert traceCollector != null; this.traceCollector = traceCollector; @@ -350,7 +409,10 @@ public DDSpanContext( // The +1 is the magic number from the tags below that we set at the end, // and "* 4 / 3" is to make sure that we don't resize immediately final int capacity = Math.max((tagsSize <= 0 ? 3 : (tagsSize + 1)) * 4 / 3, 8); - this.unsafeTags = TagMap.create(capacity); + this.unsafeTags = + readThroughParent != null + ? TagMap.createFromParent(readThroughParent) + : TagMap.create(capacity); // must set this before setting the service and resource names below this.profilingContextIntegration = profilingContextIntegration; diff --git a/dd-trace-core/src/test/java/datadog/trace/core/WithTracerTagsVersionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/WithTracerTagsVersionTest.java new file mode 100644 index 00000000000..fb859ae0433 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/WithTracerTagsVersionTest.java @@ -0,0 +1,42 @@ +package datadog.trace.core; + +import static datadog.trace.api.config.TracerConfig.SPLIT_BY_TAGS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.trace.api.Config; +import datadog.trace.api.TagMap; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.test.junit.utils.config.WithConfig; +import org.junit.jupiter.api.Test; + +/** + * {@code withTracerTags} keeps {@code version} OUT of the trace-level bundle so a per-span {@code + * removeTag(VERSION)} doesn't mint a read-through tombstone -- EXCEPT when {@code version} is a + * split-service tag, where the {@code TagInterceptor} must still see it to derive the service name + * (regression guard for the level-split consumer). + */ +class WithTracerTagsVersionTest extends DDCoreJavaSpecification { + + private static TagMap tracerTagsWithVersion(Config config) { + TagMap userTags = TagMap.create(); + userTags.set(Tags.VERSION, "1.2.3"); + return CoreTracer.withTracerTags(userTags, config, null); + } + + @Test + void versionStrippedFromBundleByDefault() { + assertNull( + tracerTagsWithVersion(Config.get()).getString(Tags.VERSION), + "version is kept out of the trace-level bundle by default (avoids per-span tombstone)"); + } + + @Test + @WithConfig(key = SPLIT_BY_TAGS, value = "version") + void versionKeptInBundleWhenSplitByVersion() { + assertEquals( + "1.2.3", + tracerTagsWithVersion(Config.get()).getString(Tags.VERSION), + "version must stay in the bundle so split-by-tags can derive the service name"); + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/TagMapReadThroughBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/TagMapReadThroughBenchmark.java new file mode 100644 index 00000000000..ed8768ef1cf --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/TagMapReadThroughBenchmark.java @@ -0,0 +1,84 @@ +package datadog.trace.util; + +import datadog.trace.api.TagMap; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Models span-build tag assembly with vs without read-through of the shared trace-level bundle. + * + *
    + *
  • copyDown — today's path: {@code putAll} the (frozen) trace-level bundle into the + * fresh span map, then set the span-specific tags. {@code putAll}-into-empty shares the + * frozen entry references (bucket-clone), so this does NOT allocate new Entry objects for the + * trace tags — its cost is cloned {@code BucketGroup}s plus the collisions caused by the + * trace tags sharing the local buckets with the span tags. + *
  • readThrough — attach the frozen bundle as a read-through parent; only the + * span-specific tags are stored locally. + *
+ * + *

Run with {@code -prof gc}; the B/op delta is the per-span allocation read-through saves. Both + * arms set the same span tags, so the delta isolates the trace-bundle handling. {@code + * traceTagCount} sweeps the bundle size — the win scales with it (more trace tags → more cloned + * BucketGroups and local collisions avoided). {@code traceTagCount = 7} ≈ a realistic + * mergedTracerTags (env, version, language, runtime-id, a propagation tag, a couple global tags). + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Warmup(iterations = 5, time = 2) +@Measurement(iterations = 5, time = 2) +@Fork(3) +@Threads(8) +public class TagMapReadThroughBenchmark { + + @Param({"3", "7", "15"}) + int traceTagCount; + + private TagMap traceTags; + + @Setup(Level.Trial) + public void setup() { + TagMap m = TagMap.create(Math.max(16, traceTagCount * 2)); + for (int i = 0; i < traceTagCount; i++) { + m.set("_dd.trace.tag." + i, "trace-value-" + i); + } + this.traceTags = m.freeze(); + } + + @Benchmark + public TagMap copyDown() { + TagMap m = TagMap.create(16); + m.putAll(traceTags); // putAll-into-empty: shares frozen entries, clones BucketGroups + setSpanTags(m); + return m; + } + + @Benchmark + public TagMap readThrough() { + // no copy; trace tags read through the shared frozen parent (fixed at construction) + TagMap m = TagMap.createFromParent(traceTags); + setSpanTags(m); + return m; + } + + private static void setSpanTags(TagMap m) { + m.set("http.method", "GET"); + m.set("http.url", "/api/checkout/cart"); + m.set("component", "spring-web-controller"); + m.set("span.kind", "server"); + m.set("http.status_code", 200); + } +} From ce521e113f2bf9e24d64a3539697302e74f71f39 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 22 Jul 2026 20:15:20 -0400 Subject: [PATCH 3/6] Mark parent field @VisibleForTesting and size the tombstone HashSet small Co-Authored-By: Claude Sonnet 5 --- internal-api/src/main/java/datadog/trace/api/TagMap.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index e0a2f2b6f70..386178b769c 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1,6 +1,7 @@ package datadog.trace.api; import datadog.trace.api.function.TriConsumer; +import datadog.trace.api.internal.VisibleForTesting; import java.util.AbstractCollection; import java.util.AbstractSet; import java.util.Arrays; @@ -1030,7 +1031,7 @@ public EntryChange next() { * final only so {@link #clear()} can detach it (to null); it is otherwise fixed at construction * and never re-pointed. Package-visible so same-package tests can assert attach/detach directly. */ - TagMap parent; + @VisibleForTesting TagMap parent; /** * Parent keys removed locally (read-through tombstones). Lazily allocated on the first such @@ -1728,7 +1729,9 @@ public Entry getAndRemove(String tag) { Entry parentEntry = parent.getEntry(tag); if (parentEntry != null) { if (this.removedFromParent == null) { - this.removedFromParent = new HashSet<>(); + // Small initial capacity: this set is rare and almost always holds only a handful of + // tombstoned keys, so the default 16-bucket HashSet table would be wasteful. + this.removedFromParent = new HashSet<>(4); } this.removedFromParent.add(tag); return localRemoved != null ? localRemoved : parentEntry; From 10bb9972bcc1962de2dd8b33528e919bdbe33e53 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 24 Jul 2026 07:09:53 -0400 Subject: [PATCH 4/6] Collapse redundant isDefinitelyEmpty() call in isEmpty() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal-api/src/main/java/datadog/trace/api/TagMap.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index 386178b769c..3af73977958 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1118,9 +1118,11 @@ public boolean isEmpty() { return true; } if (this.removedFromParent == null) { - // no local entries and no tombstones -> empty iff the whole ancestor chain is empty (nothing - // shadows it). Ancestors are frozen (no tombstones), so exact == definite here. - return parent.isDefinitelyEmpty(); + // A live parent is never definitely-empty: createFromParent drops an empty parent (and copy() + // only forwards an already-vetted parent), and the parent is frozen so it can't become empty. + // So with no local entries and no tombstones, the parent contributes at least one visible + // entry -> this map is non-empty. + return false; } // size == 0 with tombstones (rare): empty iff every visible ancestor entry is tombstoned return this.visibleParentCount() == 0; From ee975b0f9c751fcc1964eb892e18057b15be25ac Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 17 Aug 2026 17:49:46 -0400 Subject: [PATCH 5/6] 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 --- .../main/java/datadog/trace/api/TagMap.java | 15 +++ .../trace/api/TagMapReadThroughTest.java | 92 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index 519b984140d..551f7c4f6eb 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1663,6 +1663,14 @@ private void putAllIntoEmptyMap(TagMap that) { } public void fillMap(Map map) { + if (this.parent != null) { + // read-through source: the bucket-only loop below sees just local entries, so it would drop + // tags visible only through the parent chain (and ignore shadowing/tombstones). Walk the full + // visible union instead -- mirrors putAllOptimizedMap. + this.forEach(map, (m, entry) -> m.put(entry.tag(), entry.objectValue())); + return; + } + Object[] thisBuckets = this.buckets; for (int i = 0; i < thisBuckets.length; ++i) { @@ -1681,6 +1689,13 @@ public void fillMap(Map map) { } public void fillStringMap(Map stringMap) { + if (this.parent != null) { + // read-through source: walk the visible union so parent-only tags aren't dropped -- see + // fillMap. + this.forEach(stringMap, (m, entry) -> m.put(entry.tag(), entry.stringValue())); + return; + } + Object[] thisBuckets = this.buckets; for (int i = 0; i < thisBuckets.length; ++i) { diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java index 14b040a8406..1fa3a364486 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java @@ -600,4 +600,96 @@ void putAfterClearBehavesAsAPlainMap() { assertEquals("x-val", child.getString("x")); assertNull(child.getString("a"), "no read-through after clear detached the parent"); } + + // --- slice 8: fillMap/fillStringMap materialize the visible union, not just local entries --- + + private static Map fillMapCollect(TagMap map) { + Map out = new HashMap<>(); + map.fillMap(out); + return out; + } + + private static Map fillStringMapCollect(TagMap map) { + Map out = new HashMap<>(); + map.fillStringMap(out); + return out; + } + + @Test + void fillMapMaterializesDedupedUnionLocalWins() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map out = fillMapCollect(child); + assertEquals(3, out.size(), "union {a, b, c} with b deduped"); + assertEquals("parent-a", out.get("a")); // inherited tag must not be dropped + assertEquals("child-b", out.get("b")); // local wins + assertEquals("child-c", out.get("c")); + } + + @Test + void fillMapHonorsTombstonedParentKeys() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + child.remove("a"); // tombstone parent's "a" + + Map out = fillMapCollect(child); + assertEquals(2, out.size()); + assertFalse(out.containsKey("a")); + assertEquals("parent-b", out.get("b")); + assertEquals("child-c", out.get("c")); + } + + @Test + void fillStringMapMaterializesDedupedUnionLocalWins() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map out = fillStringMapCollect(child); + assertEquals(3, out.size()); + assertEquals("parent-a", out.get("a")); // inherited tag preserved + assertEquals("child-b", out.get("b")); + assertEquals("child-c", out.get("c")); + } + + @Test + void fillStringMapHonorsTombstonedParentKeys() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("a"); // tombstone parent's "a" + + Map out = fillStringMapCollect(child); + assertEquals(1, out.size()); + assertFalse(out.containsKey("a")); + assertEquals("parent-b", out.get("b")); + } + + @Test + void fillMapAcrossChainMatchesEquivalentFlatMap() { + TagMap leaf = threeLevelLeaf(); + + TagMap flat = TagMap.create(); + flat.set("a", "gp-a"); + flat.set("b", "mid-b"); + flat.set("c", "leaf-c"); + flat.set("d", "mid-d"); + flat.set("e", "leaf-e"); + + assertEquals(fillMapCollect(flat), fillMapCollect(leaf)); + assertEquals(fillStringMapCollect(flat), fillStringMapCollect(leaf)); + } + + @Test + void fillMapWithoutParentFillsOnlyLocalEntries() { + // regression: the no-parent fast path is unchanged + TagMap flat = TagMap.create(); + flat.set("x", "x-val"); + flat.set("y", "y-val"); + + Map out = fillMapCollect(flat); + assertEquals(2, out.size()); + assertEquals("x-val", out.get("x")); + assertEquals("y-val", out.get("y")); + } } From 8715e33f2a964a9fe747c6a4968fca0a26604dc5 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 17 Aug 2026 17:49:59 -0400 Subject: [PATCH 6/6] 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 --- .../main/java/datadog/trace/api/TagMap.java | 6 ++++- .../trace/api/TagMapReadThroughTest.java | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index 551f7c4f6eb..39160ae11ff 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -96,7 +96,11 @@ public static final TagMap createFromParent(TagMap parent) { if (!parent.frozen) { throw new IllegalStateException("read-through parent must be frozen"); } - if (parent.isDefinitelyEmpty()) { + // Exact, not isDefinitelyEmpty(): a multi-level parent can be observationally empty while a + // local level still holds entries (all of them tombstoned by a nearer level). Dropping such a + // parent keeps isEmpty()'s no-tombstone fast path valid -- an attached parent always + // contributes at least one visible entry. + if (parent.isEmpty()) { parent = null; } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java index 1fa3a364486..b424c5793ce 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java @@ -502,6 +502,29 @@ void intermediateAncestorTombstoneIsHonoredByBulkViews() { assertFalse(viaIterator.containsKey("a")); } + @Test + void observationallyEmptyIntermediateParentIsDroppedNotAttached() { + // A frozen mid level with no local entries whose every inherited key is tombstoned is + // observationally empty even though a local (grandparent) level still holds entries. + // createFromParent must drop it (exact isEmpty), so the leaf stays consistent: isEmpty() must + // agree with size()/lookup/iteration. + TagMap grandparent = TagMap.create(); + grandparent.set("a", "gp-a"); + grandparent.freeze(); + + TagMap mid = TagMap.createFromParent(grandparent); + mid.remove("a"); // tombstone the only inherited key -> mid is observationally empty + mid.freeze(); + assertTrue(mid.isEmpty()); + + TagMap leaf = TagMap.createFromParent(mid); + assertEquals(0, leaf.size()); + assertTrue(leaf.isEmpty(), "isEmpty() must agree with size()==0"); + assertNull(leaf.getString("a")); + assertTrue(collect(leaf).isEmpty()); + assertFalse(leaf.iterator().hasNext()); + } + @Test void chainReadThroughMatchesEquivalentFlatMap() { TagMap leaf = threeLevelLeaf();