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..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,11 +1,13 @@ 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; 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 +50,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 +77,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 +1018,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. + */ + @VisibleForTesting 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 +1066,7 @@ private TagMap(Object[] buckets, int size) { this.buckets = buckets; this.size = size; this.frozen = true; + this.parent = null; } public boolean isOptimized() { @@ -1015,12 +1075,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 +1250,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 +1324,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 +1373,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 +1496,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 +1509,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 +1715,34 @@ 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) { + // 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; + } + } + } + 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 +1780,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 +1825,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 +1874,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 +1922,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 +1960,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 +2022,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 +2142,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 +2157,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 +2194,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 +2737,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 +2762,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 +2801,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"); + } +}