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..0b1e6ef6a84 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,19 @@ 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. + */ + public static final TagMap createFromParent(TagMap parent) { + if (parent != null && !parent.frozen) { + throw new IllegalStateException("read-through parent must be frozen"); + } + return new TagMap(parent); + } + /** Creates a new TagMap.Ledger */ public static final Ledger ledger() { return new Ledger(); @@ -991,15 +1004,45 @@ 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 (level-split phase 1). When non-null, reads that miss + * the local buckets fall through to the parent; a local entry shadows the parent's (local-wins). + * Phase 1 is single-parent by design (anti-false-generalization); generalizing to multiple + * flattened parents is additive. Must be frozen when attached, so it is safely shareable. + */ + private final 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 once, + * here at construction, and is final — there is no re-parenting, 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 +1050,7 @@ private TagMap(Object[] buckets, int size) { this.buckets = buckets; this.size = size; this.frozen = true; + this.parent = null; } public boolean isOptimized() { @@ -1015,12 +1059,63 @@ 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 parent entries not shadowed locally or tombstoned (the read-through addition). + */ + private int visibleParentCount() { + Object[] parentBuckets = this.parent.buckets; + Object[] thisBuckets = this.buckets; + int count = 0; + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + Object localBucket = thisBuckets[i]; + if (parentBucket instanceof Entry) { + if (parentEntryVisibleInBucket(localBucket, (Entry) parentBucket)) 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 && parentEntryVisibleInBucket(localBucket, parentEntry)) + 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 parent is empty (nothing shadows it) + return parent.isEmpty(); + } + // size == 0 with tombstones (rare): empty iff every parent entry is tombstoned + return this.visibleParentCount() == 0; + } + + public boolean isDefinitelyEmpty() { + return this.size == 0 && (this.parent == null || this.parent.isDefinitelyEmpty()); + } + + public int estimateSize() { + // Upper bound: local + parent, ignoring read-through shadowing/removals (over-counts). + return this.parent == null ? this.size : this.size + this.parent.estimateSize(); } @Deprecated @@ -1128,26 +1223,59 @@ 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 a parent entry is visible through this child at its (shared) bucket: not tombstoned and + * not shadowed by a local entry. Exploits universal hashing — by {@code _hash}, the only local + * entry that could shadow {@code parentEntry} lives in this map's same-index bucket, so we + * compare against {@code localBucket} alone, reusing {@code parentEntry}'s cached hash (no + * re-hash, no full-map probe). + */ + private boolean parentEntryVisibleInBucket(Object localBucket, Entry parentEntry) { + if (this.removedFromParent != null && this.removedFromParent.contains(parentEntry.tag)) { + return false; // tombstoned: removed locally + } + return findInBucket(localBucket, parentEntry.hash(), parentEntry.tag) + == null; // not shadowed by a local entry + } + @Deprecated @Override public Object put(@Nonnull String tag, Object value) { @@ -1204,6 +1332,12 @@ public Entry getAndSet(@Nullable 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 +1429,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(); @@ -1506,6 +1641,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 +1704,14 @@ public Entry getAndRemove(String tag) { } public TagMap copy() { - TagMap copy = new TagMap(); + // Construct with the same (frozen, shared) parent up front — parent is final. 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 +1749,35 @@ 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) { + Object[] localBuckets = this.buckets; + Object[] parentBuckets = this.parent.buckets; // leaf parent: same length, same bucket per key + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + Object localBucket = localBuckets[i]; + if (parentBucket instanceof Entry) { + Entry parentEntry = (Entry) parentBucket; + if (parentEntryVisibleInBucket(localBucket, parentEntry)) 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 && parentEntryVisibleInBucket(localBucket, parentEntry)) + consumer.accept(parentEntry); + } + } + } + } } public void forEach(T thisObj, BiConsumer consumer) { @@ -1600,6 +1796,36 @@ 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) { + Object[] localBuckets = this.buckets; + Object[] parentBuckets = this.parent.buckets; // leaf parent: same length, same bucket per key + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + Object localBucket = localBuckets[i]; + if (parentBucket instanceof Entry) { + Entry parentEntry = (Entry) parentBucket; + if (parentEntryVisibleInBucket(localBucket, parentEntry)) + 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 && parentEntryVisibleInBucket(localBucket, parentEntry)) { + consumer.accept(thisObj, parentEntry); + } + } + } + } + } } public void forEach( @@ -1619,6 +1845,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) { + Object[] localBuckets = this.buckets; + Object[] parentBuckets = this.parent.buckets; // leaf parent: same length, same bucket per key + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + Object localBucket = localBuckets[i]; + if (parentBucket instanceof Entry) { + Entry parentEntry = (Entry) parentBucket; + if (parentEntryVisibleInBucket(localBucket, parentEntry)) + 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 && parentEntryVisibleInBucket(localBucket, parentEntry)) { + consumer.accept(thisObj, otherObj, parentEntry); + } + } + } + } + } } public void clear() { @@ -1683,7 +1940,10 @@ 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 +2061,12 @@ String toInternalString() { } abstract static class IteratorBase { - private final Object[] buckets; + private final TagMap map; + private final Object[] localBuckets; + + // current array being walked: local buckets first, then the parent's (read-through union) + private Object[] buckets; + private boolean inParent = false; private Entry nextEntry; @@ -1811,18 +2076,16 @@ abstract static class IteratorBase { private int groupIndex = 0; IteratorBase(TagMap map) { + this.map = map; + this.localBuckets = map.buckets; 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 +2113,35 @@ final Entry nextEntryOrNull() { } private final Entry advance() { + while (true) { + Entry tagEntry = this.rawAdvance(); + if (tagEntry != null) { + // local entries emit as-is; parent entries only if not shadowed locally or tombstoned. + // bucketIndex indexes the parent buckets here, which (universal hashing) line up with the + // same-index local bucket — so localBuckets[bucketIndex] is the bucket that could shadow. + if (!this.inParent + || this.map.parentEntryVisibleInBucket( + this.localBuckets[this.bucketIndex], tagEntry)) { + return tagEntry; + } + continue; // parent entry shadowed/tombstoned -> skip + } + + // current array exhausted; switch to the parent's buckets once (read-through union) + if (!this.inParent && this.map.parent != null) { + this.inParent = true; + this.buckets = this.map.parent.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 +2659,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 +2684,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 +2723,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..aecf7fbce5b --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java @@ -0,0 +1,316 @@ +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.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) TagMap.create(); + parent.set("a", "parent-a"); + parent.set("b", "parent-b"); + parent.freeze(); + return parent; + } + + @Test + void readsThroughToParentOnMiss() { + TagMap child = (TagMap) 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) 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) 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) TagMap.createFromParent((TagMap) TagMap.create().freeze()); + assertTrue(emptyOverEmpty.isEmpty()); + assertTrue(emptyOverEmpty.isDefinitelyEmpty()); + + TagMap emptyOverNonEmpty = (TagMap) TagMap.createFromParent(frozenParent()); + assertFalse(emptyOverNonEmpty.isEmpty(), "a non-empty parent makes the map non-empty"); + assertFalse(emptyOverNonEmpty.isDefinitelyEmpty()); + + assertTrue(((TagMap) TagMap.create()).isDefinitelyEmpty()); + } + + @Test + void parentMustBeFrozen() { + TagMap mutableParent = (TagMap) TagMap.create(); + assertThrows(IllegalStateException.class, () -> TagMap.createFromParent(mutableParent)); + } + + // --- slice 2: removal / tombstones --- + + @Test + void removingParentKeyHidesItFromChildButNotFromParent() { + TagMap parent = frozenParent(); + TagMap child = (TagMap) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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 4: behavior-identical to a copy-down / flat map --- + + @Test + void copyIsObservationallyIdentical() { + TagMap child = (TagMap) TagMap.createFromParent(frozenParent()); // {a, b} + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + TagMap copy = (TagMap) 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) TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + TagMap copy = (TagMap) 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) TagMap.createFromParent(frozenParent()); + child.remove("a"); // tombstone "a" + + TagMap copy = (TagMap) 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) TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); + child.set("c", "child-c"); + + TagMap flat = (TagMap) 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) TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + TagMap frozen = (TagMap) 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 + } +}