diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java
new file mode 100644
index 00000000000..557dc730687
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java
@@ -0,0 +1,417 @@
+package datadog.trace.api;
+
+import datadog.trace.api.TagMap.BucketGroup;
+import datadog.trace.api.TagMap.Entry;
+import datadog.trace.api.TagMap.EntryReader;
+import datadog.trace.api.function.TriConsumer;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+/**
+ * The hash {@link IdStore}: known tags in their own bucket array ({@link HashSupport}), keyed by
+ * id, reading through to the frozen parent's id store.
+ *
+ *
The buckets are the exact state: a read probes them, and a local miss reads through to the
+ * parent if there is one. Removing an inherited tag puts that tag's shared tombstone ({@link
+ * Entry#tombstoneOf}) in the local buckets, so it allocates nothing. A tombstone is never counted,
+ * iterated or returned.
+ */
+final class HashIdStore extends IdStore {
+ /** An empty parentless store over one bucket, for {@link TagMap#EMPTY}, which is frozen. */
+ static HashIdStore createEmpty() {
+ return new HashIdStore(new Object[1]);
+ }
+
+ /** Walks the leaf's buckets, then each ancestor's, skipping tombstones and shadowed entries. */
+ private static final class HashCursor implements Cursor {
+ private final HashIdStore leaf;
+ private HashIdStore level;
+ private final HashSupport.BucketWalker walker = new HashSupport.BucketWalker();
+
+ HashCursor(HashIdStore leaf) {
+ this.leaf = leaf;
+ this.level = leaf;
+ this.walker.reset(leaf.buckets);
+ }
+
+ @Override
+ public EntryReader next() {
+ HashIdStore level = this.level;
+ while (level != null) {
+ Entry entry = this.walker.next();
+ if (entry != null) {
+ if (!entry.tombstone
+ && (level == this.leaf || this.leaf.parentEntryVisible(entry, level))) {
+ return entry;
+ }
+ continue;
+ }
+ level = level.parent;
+ this.level = level;
+ if (level != null) {
+ this.walker.reset(level.buckets);
+ }
+ }
+ return null;
+ }
+ }
+
+ final Object[] buckets;
+
+ /** Live local entries, not counting tombstones. */
+ int count;
+
+ /** The frozen parent's id store, or null. */
+ final HashIdStore parent;
+
+ /** Whether the buckets may hold a tombstone; a hint that keeps the common case on fast paths. */
+ boolean hasTombstones;
+
+ HashIdStore(IdStore parent) {
+ this.buckets = HashSupport.newBuckets();
+ this.parent = (HashIdStore) parent;
+ }
+
+ private HashIdStore(Object[] buckets) {
+ this.buckets = buckets;
+ this.parent = null;
+ }
+
+ @Override
+ public Entry getEntry(long tagId) {
+ if (!KnownTagCodec.isKnown(tagId)) {
+ throw notKnown(tagId);
+ }
+ return this.getVisible(tagId);
+ }
+
+ /** The stored Entry is itself a stable reader, so this is {@link #getEntry}. */
+ @Override
+ public Entry getEntryReader(long tagId) {
+ return this.getEntry(tagId);
+ }
+
+ /** {@link #getEntry} for a validated id. */
+ private Entry getVisible(long tagId) {
+ Entry local = HashSupport.findById(this.buckets, Entry.bucketHash(tagId), tagId);
+ if (local != null) {
+ return local.tombstone ? null : local;
+ }
+ HashIdStore parent = this.parent;
+ return parent == null ? null : parent.getVisible(tagId);
+ }
+
+ @Override
+ public Object getValue(long tagId) {
+ Entry entry = this.getEntry(tagId);
+ return entry == null ? null : entry.objectValue();
+ }
+
+ @Override
+ public String getString(long tagId) {
+ Entry entry = this.getEntry(tagId);
+ return entry == null ? null : entry.stringValue();
+ }
+
+ @Override
+ public void set(long tagId, Object value) {
+ if (value == null) {
+ this.remove(tagId); // a null value removes the tag
+ return;
+ }
+ this.put(Entry.newAnyEntry(tagId, value));
+ }
+
+ @Override
+ public void set(long tagId, CharSequence value) {
+ if (value == null) {
+ this.remove(tagId); // a null value removes the tag
+ return;
+ }
+ this.put(Entry.newObjectEntry(tagId, value));
+ }
+
+ @Override
+ public void set(long tagId, boolean value) {
+ this.put(Entry.newBooleanEntry(tagId, value));
+ }
+
+ @Override
+ public void set(long tagId, int value) {
+ this.put(Entry.newIntEntry(tagId, value));
+ }
+
+ @Override
+ public void set(long tagId, long value) {
+ this.put(Entry.newLongEntry(tagId, value));
+ }
+
+ @Override
+ public void set(long tagId, float value) {
+ this.put(Entry.newFloatEntry(tagId, value));
+ }
+
+ @Override
+ public void set(long tagId, double value) {
+ this.put(Entry.newDoubleEntry(tagId, value));
+ }
+
+ @Override
+ public Entry put(EntryReader entryReader) {
+ Entry entry = entryReader.entry();
+ if (entry == null) {
+ return null; // a reader with no entry is a no-op, as for TagMap.set
+ }
+ Entry replaced = HashSupport.put(this.buckets, entry);
+ if (replaced == null || replaced.tombstone) {
+ this.count++; // a tombstone replaced is a removal undone: no prior local entry
+ return null;
+ }
+ return replaced;
+ }
+
+ @Override
+ public Entry getAndPut(EntryReader entryReader) {
+ Entry entry = entryReader.entry();
+ if (entry == null) {
+ return null; // a reader with no entry is a no-op
+ }
+ Entry replaced = HashSupport.put(this.buckets, entry);
+ if (replaced != null && !replaced.tombstone) {
+ return replaced; // replaced a local entry -> that is the prior value
+ }
+ this.count++;
+ HashIdStore parent = this.parent;
+ if (replaced != null || parent == null) {
+ return null; // it was tombstoned, so not visible, or nothing to inherit
+ }
+ return parent.getVisible(entry.tagHash);
+ }
+
+ @Override
+ public Entry remove(long tagId) {
+ if (!KnownTagCodec.isKnown(tagId)) {
+ throw notKnown(tagId);
+ }
+ int hash = Entry.bucketHash(tagId);
+
+ Entry local = HashSupport.findById(this.buckets, hash, tagId);
+ if (local != null) {
+ if (local.tombstone) {
+ return null; // already removed
+ }
+ HashSupport.remove(this.buckets, hash, local.tag);
+ this.count--;
+ }
+ HashIdStore parent = this.parent;
+ if (parent != null) {
+ // Read-through: hide an inherited value with a tombstone. The prior *visible* value is the
+ // local entry if there was one, otherwise the inherited one.
+ Entry inherited = parent.getVisible(tagId);
+ if (inherited != null) {
+ HashSupport.put(this.buckets, Entry.tombstoneOf(tagId));
+ this.hasTombstones = true;
+ return local != null ? local : inherited;
+ }
+ }
+ return local;
+ }
+
+ @Override
+ int size() {
+ return this.parent == null ? this.count : this.count + this.visibleParentCount();
+ }
+
+ /** Ancestor entries not shadowed or tombstoned by a nearer level, nearest-level-wins. */
+ private int visibleParentCount() {
+ // a plain loop, not forEachParent: counting through a lambda would allocate a counter
+ int count = 0;
+ for (HashIdStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) {
+ Object[] parentBuckets = ancestor.buckets;
+ for (int i = 0; i < parentBuckets.length; ++i) {
+ Object bucket = parentBuckets[i];
+ if (bucket instanceof Entry) {
+ Entry entry = (Entry) bucket;
+ if (!entry.tombstone && this.parentEntryVisible(entry, ancestor)) {
+ count++;
+ }
+ } else if (bucket instanceof BucketGroup) {
+ for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) {
+ for (int j = 0; j < BucketGroup.LEN; ++j) {
+ Entry entry = group._entryAt(j);
+ if (entry != null && !entry.tombstone && this.parentEntryVisible(entry, ancestor)) {
+ count++;
+ }
+ }
+ }
+ }
+ }
+ }
+ return count;
+ }
+
+ @Override
+ boolean isEmpty() {
+ if (this.count != 0) {
+ return false;
+ }
+ HashIdStore parent = this.parent;
+ if (parent == null) {
+ return true;
+ }
+ if (!this.hasTombstones) {
+ return parent.isEmpty(); // nothing hidden: the parent's tags are all visible
+ }
+ return this.visibleParentCount() == 0;
+ }
+
+ @Override
+ boolean isDefinitelyEmpty() {
+ for (HashIdStore level = this; level != null; level = level.parent) {
+ if (level.count != 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ int estimateSize() {
+ int total = 0;
+ for (HashIdStore level = this; level != null; level = level.parent) {
+ total += level.count;
+ }
+ return total;
+ }
+
+ @Override
+ boolean hasParent() {
+ return this.parent != null;
+ }
+
+ /**
+ * Whether {@code parentEntry}, from the ancestor level {@code fromAncestor}, is visible through
+ * this leaf: no nearer level holds an entry or a tombstone for it.
+ */
+ boolean parentEntryVisible(Entry parentEntry, HashIdStore fromAncestor) {
+ long tagId = parentEntry.tagHash;
+ int hash = parentEntry.hash();
+ for (HashIdStore nearer = this; nearer != fromAncestor; nearer = nearer.parent) {
+ if (HashSupport.findById(nearer.buckets, hash, tagId) != null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ void forEach(Consumer super EntryReader> consumer) {
+ HashSupport.forEach(this.buckets, consumer);
+ if (this.parent != null) {
+ this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry));
+ }
+ }
+
+ @Override
+ void forEach(T thisObj, BiConsumer consumer) {
+ HashSupport.forEach(this.buckets, thisObj, consumer);
+ if (this.parent != null) {
+ this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry));
+ }
+ }
+
+ @Override
+ void forEach(T thisObj, U otherObj, TriConsumer consumer) {
+ HashSupport.forEach(this.buckets, thisObj, otherObj, consumer);
+ if (this.parent != null) {
+ this.forEachParent(thisObj, otherObj, consumer);
+ }
+ }
+
+ private void forEachParent(
+ T thisObj, U otherObj, TriConsumer consumer) {
+ for (HashIdStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) {
+ Object[] parentBuckets = ancestor.buckets;
+ for (int i = 0; i < parentBuckets.length; ++i) {
+ Object bucket = parentBuckets[i];
+ if (bucket instanceof Entry) {
+ Entry entry = (Entry) bucket;
+ if (!entry.tombstone && this.parentEntryVisible(entry, ancestor)) {
+ consumer.accept(thisObj, otherObj, entry);
+ }
+ } else if (bucket instanceof BucketGroup) {
+ for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) {
+ for (int j = 0; j < BucketGroup.LEN; ++j) {
+ Entry entry = group._entryAt(j);
+ if (entry != null && !entry.tombstone && this.parentEntryVisible(entry, ancestor)) {
+ consumer.accept(thisObj, otherObj, entry);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ Cursor cursor() {
+ return new HashCursor(this);
+ }
+
+ @Override
+ IdStore copy() {
+ HashIdStore copy = new HashIdStore(this.parent);
+ HashSupport.copyInto(copy.buckets, this.buckets);
+ copy.count = this.count;
+ copy.hasTombstones = this.hasTombstones;
+ return copy;
+ }
+
+ @Override
+ void putAllLocal(IdStore other) {
+ HashIdStore that = (HashIdStore) other;
+ if (that.count == 0) {
+ return;
+ }
+ if (this.hasTombstones) {
+ // A bucket merge would count a replaced tombstone as a replacement, so put one at a time.
+ HashSupport.forEach(that.buckets, this, (self, entryReader) -> self.put(entryReader));
+ return;
+ }
+ if (this.count == 0) {
+ HashSupport.copyInto(this.buckets, that.buckets);
+ this.count = that.count;
+ } else {
+ this.count += HashSupport.merge(this.buckets, that.buckets);
+ }
+ }
+
+ @Override
+ void fillMapLocal(Map super String, Object> map) {
+ HashSupport.fillMap(this.buckets, map);
+ }
+
+ @Override
+ void fillStringMapLocal(Map super String, ? super String> map) {
+ HashSupport.fillStringMap(this.buckets, map);
+ }
+
+ @Override
+ void checkIntegrity() {
+ HashSupport.checkBuckets(this.buckets);
+ if (this.count != this.computeSize()) {
+ throw new IllegalStateException("incorrect size");
+ }
+ }
+
+ @Override
+ int computeSize() {
+ return HashSupport.count(this.buckets);
+ }
+
+ @Override
+ String toInternalString() {
+ return HashSupport.toInternalString(this.buckets);
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/HashStore.java b/internal-api/src/main/java/datadog/trace/api/HashStore.java
new file mode 100644
index 00000000000..3b746cb5244
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java
@@ -0,0 +1,364 @@
+package datadog.trace.api;
+
+import datadog.apicontract.Restricted;
+import datadog.trace.api.TagMap.BucketGroup;
+import datadog.trace.api.TagMap.Entry;
+import datadog.trace.api.TagMap.EntryReader;
+import datadog.trace.api.function.TriConsumer;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+/**
+ * The custom-tag half of a {@link TagMap}: custom tags by name, in their own bucket array ({@link
+ * HashSupport}), reading through to the frozen parent's custom store. Name-keyed methods take only
+ * custom tag names (asserted): a known tag is resolved to its id first, and goes to the {@link
+ * IdStore}.
+ *
+ * Created lazily by its owner -- a {@link TagMap}, or a span in the tracer core -- on the first
+ * custom write ({@link #create}); until then the owner borrows its parent's custom store, if any,
+ * read-only. No locking: guarded by the owner's lock.
+ */
+@Restricted(allowedIn = {"datadog.trace.core"})
+public final class HashStore {
+ final Object[] buckets;
+
+ /** Local entries. */
+ int count;
+
+ /** The frozen parent's custom store, or null. */
+ final HashStore parent;
+
+ /**
+ * Parent keys removed locally (read-through tombstones): a tombstone stops read-through for its
+ * key. Allocated on the first such removal, so while it is {@code null} the hot paths skip the
+ * tombstone checks.
+ */
+ private Set removedFromParent;
+
+ /**
+ * A fresh custom store for an owner's first custom write, reading through to {@code borrowed},
+ * the store it borrowed until now (may be null).
+ */
+ public static HashStore create(HashStore borrowed) {
+ return new HashStore(borrowed);
+ }
+
+ HashStore(HashStore parent) {
+ this.buckets = HashSupport.newBuckets();
+ this.parent = parent;
+ }
+
+ /** Exact count, including the visible part of the read-through chain. */
+ int size() {
+ return this.parent == null ? this.count : this.count + this.visibleParentCount();
+ }
+
+ /** Ancestor entries not shadowed or tombstoned by a nearer level, nearest-level-wins. */
+ private int visibleParentCount() {
+ // a plain loop, not forEachParent: counting through a lambda would allocate a counter
+ int count = 0;
+ for (HashStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) {
+ Object[] parentBuckets = ancestor.buckets;
+ for (int i = 0; i < parentBuckets.length; ++i) {
+ Object bucket = parentBuckets[i];
+ if (bucket instanceof Entry) {
+ Entry entry = (Entry) bucket;
+ if (this.parentEntryVisible(entry, ancestor)) {
+ count++;
+ }
+ } else if (bucket instanceof BucketGroup) {
+ for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) {
+ for (int j = 0; j < BucketGroup.LEN; ++j) {
+ Entry entry = group._entryAt(j);
+ if (entry != null && this.parentEntryVisible(entry, ancestor)) {
+ count++;
+ }
+ }
+ }
+ }
+ }
+ }
+ return count;
+ }
+
+ boolean isEmpty() {
+ if (this.count != 0) {
+ return false;
+ }
+ HashStore parent = this.parent;
+ if (parent == null) {
+ return true;
+ }
+ if (this.removedFromParent == null) {
+ return parent.isEmpty(); // nothing hidden: the parent's tags are all visible
+ }
+ return this.visibleParentCount() == 0;
+ }
+
+ /** Cheap: no level of the chain holds a local entry (ignores shadowing and tombstones). */
+ boolean isDefinitelyEmpty() {
+ for (HashStore level = this; level != null; level = level.parent) {
+ if (level.count != 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Upper bound: the local counts of every level. */
+ int estimateSize() {
+ int total = 0;
+ for (HashStore level = this; level != null; level = level.parent) {
+ total += level.count;
+ }
+ return total;
+ }
+
+ /** The custom tag {@code tag}'s entry, reading through to the parent chain. */
+ public Entry get(String tag) {
+ assert KnownTagCodec.keyOf(tag) == 0 : "'" + tag + "' is a known tag; look it up by its id";
+ return this.get(tag, Entry.bucketHash(Entry.customHash(tag)));
+ }
+
+ private Entry get(String tag, int hash) {
+ Entry local = HashSupport.find(this.buckets, hash, tag);
+ if (local != null) {
+ return local;
+ }
+ HashStore 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.get(tag, hash);
+ }
+
+ /*
+ * Sets the custom tag {@code tag}, which must not be a known tag's name.
+ */
+
+ public void set(String tag, Object value) {
+ if (value == null) {
+ this.remove(tag); // a null value removes the tag
+ return;
+ }
+ this.put(Entry.newAnyEntry(tag, value));
+ }
+
+ public void set(String tag, CharSequence value) {
+ if (value == null) {
+ this.remove(tag); // a null value removes the tag
+ return;
+ }
+ this.put(Entry.newObjectEntry(tag, value));
+ }
+
+ public void set(String tag, boolean value) {
+ this.put(Entry.newBooleanEntry(tag, value));
+ }
+
+ public void set(String tag, int value) {
+ this.put(Entry.newIntEntry(tag, value));
+ }
+
+ public void set(String tag, long value) {
+ this.put(Entry.newLongEntry(tag, value));
+ }
+
+ public void set(String tag, float value) {
+ this.put(Entry.newFloatEntry(tag, value));
+ }
+
+ public void set(String tag, double value) {
+ this.put(Entry.newDoubleEntry(tag, value));
+ }
+
+ /**
+ * Inserts or replaces a custom tag's entry, returning the local entry it replaced (or null). Does
+ * not consult the read-through parent.
+ */
+ public Entry put(Entry newEntry) {
+ // 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);
+ }
+ Entry replaced = HashSupport.put(this.buckets, newEntry);
+ if (replaced == null) {
+ this.count++;
+ }
+ return replaced;
+ }
+
+ /** {@link #put}, returning the prior visible entry -- local, else read through. */
+ public Entry getAndPut(Entry newEntry) {
+ // Capture whether the key was tombstoned BEFORE put clears it: a tombstoned key had no visible
+ // prior value (it was removed), so getAndPut must report null rather than the parent's.
+ boolean wasTombstoned =
+ this.removedFromParent != null && this.removedFromParent.contains(newEntry.tag);
+
+ Entry priorLocal = this.put(newEntry);
+ if (priorLocal != null) {
+ return priorLocal;
+ }
+ if (wasTombstoned || this.parent == null) {
+ return null;
+ }
+ return this.parent.get(newEntry.tag, newEntry.hash());
+ }
+
+ /**
+ * Removes the custom tag {@code tag}, returning the prior visible entry. Removing a key the
+ * parent chain still exposes also installs a tombstone, so it stops reading through.
+ */
+ public Entry remove(String tag) {
+ assert KnownTagCodec.keyOf(tag) == 0 : "'" + tag + "' is a known tag; remove it by its id";
+ int hash = Entry.bucketHash(Entry.customHash(tag));
+ Entry localRemoved = HashSupport.remove(this.buckets, hash, tag);
+ if (localRemoved != null) {
+ this.count--;
+ }
+
+ HashStore parent = this.parent;
+ if (parent != null) {
+ boolean alreadyTombstoned =
+ this.removedFromParent != null && this.removedFromParent.contains(tag);
+ if (!alreadyTombstoned) {
+ Entry parentEntry = parent.get(tag, hash);
+ 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;
+ }
+
+ /**
+ * Whether {@code parentEntry}, from the ancestor level {@code fromAncestor}, is visible through
+ * this leaf: not shadowed and not tombstoned by any nearer level.
+ */
+ boolean parentEntryVisible(Entry parentEntry, HashStore fromAncestor) {
+ int hash = parentEntry.hash();
+ String tag = parentEntry.tag;
+ for (HashStore nearer = this; nearer != fromAncestor; nearer = nearer.parent) {
+ if (nearer.removedFromParent != null && nearer.removedFromParent.contains(tag)) {
+ return false;
+ }
+ if (HashSupport.find(nearer.buckets, hash, tag) != null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Visits the local entries, then the visible inherited ones. */
+ void forEach(Consumer super EntryReader> consumer) {
+ HashSupport.forEach(this.buckets, consumer);
+ if (this.parent != null) {
+ this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry));
+ }
+ }
+
+ void forEach(T thisObj, BiConsumer consumer) {
+ HashSupport.forEach(this.buckets, thisObj, consumer);
+ if (this.parent != null) {
+ this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry));
+ }
+ }
+
+ void forEach(T thisObj, U otherObj, TriConsumer consumer) {
+ HashSupport.forEach(this.buckets, thisObj, otherObj, consumer);
+ if (this.parent != null) {
+ this.forEachParent(thisObj, otherObj, consumer);
+ }
+ }
+
+ private void forEachParent(
+ T thisObj, U otherObj, TriConsumer consumer) {
+ for (HashStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) {
+ Object[] parentBuckets = ancestor.buckets;
+ for (int i = 0; i < parentBuckets.length; ++i) {
+ Object bucket = parentBuckets[i];
+ if (bucket instanceof Entry) {
+ Entry entry = (Entry) bucket;
+ if (this.parentEntryVisible(entry, ancestor)) {
+ consumer.accept(thisObj, otherObj, entry);
+ }
+ } else if (bucket instanceof BucketGroup) {
+ for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) {
+ for (int j = 0; j < BucketGroup.LEN; ++j) {
+ Entry entry = group._entryAt(j);
+ if (entry != null && this.parentEntryVisible(entry, ancestor)) {
+ consumer.accept(thisObj, otherObj, entry);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * An independently mutable copy: the same (frozen, shared) parent, this store's own entries
+ * (shared Entry objects) and its tombstones.
+ */
+ HashStore copy() {
+ HashStore copy = new HashStore(this.parent);
+ HashSupport.copyInto(copy.buckets, this.buckets);
+ copy.count = this.count;
+ if (this.removedFromParent != null) {
+ copy.removedFromParent = new HashSet<>(this.removedFromParent);
+ }
+ return copy;
+ }
+
+ /** Copies the entries of the parentless store {@code that}, sharing Entry objects. */
+ void putAllLocal(HashStore that) {
+ if (that.count == 0) {
+ return;
+ }
+ if (this.count == 0) {
+ HashSupport.copyInto(this.buckets, that.buckets);
+ this.count = that.count;
+ } else {
+ this.count += HashSupport.merge(this.buckets, that.buckets);
+ }
+ }
+
+ void fillMapLocal(Map super String, Object> map) {
+ HashSupport.fillMap(this.buckets, map);
+ }
+
+ void fillStringMapLocal(Map super String, ? super String> map) {
+ HashSupport.fillStringMap(this.buckets, map);
+ }
+
+ /** Tests only: throws if the store's structure is inconsistent. */
+ void checkIntegrity() {
+ HashSupport.checkBuckets(this.buckets);
+ if (this.count != this.computeSize()) {
+ throw new IllegalStateException("incorrect size");
+ }
+ }
+
+ /** Tests only: the local count, recomputed from the structure. */
+ int computeSize() {
+ return HashSupport.count(this.buckets);
+ }
+
+ /** For deep debugging. */
+ String toInternalString() {
+ return HashSupport.toInternalString(this.buckets);
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/HashSupport.java b/internal-api/src/main/java/datadog/trace/api/HashSupport.java
index 1c7874433ab..368244f3264 100644
--- a/internal-api/src/main/java/datadog/trace/api/HashSupport.java
+++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java
@@ -9,35 +9,50 @@
import java.util.function.Consumer;
/**
- * The raw bucket mechanics of {@link TagMap}, over one fixed-size {@code Object[]} of buckets of
- * shareable, immutable {@link Entry} objects. A bucket holds a lone Entry directly; colliding
- * entries share a fixed-size {@link BucketGroup}, chained when one fills.
+ * The raw bucket mechanics both tag stores share, over one fixed-size {@code Object[]} of buckets
+ * of shareable, immutable {@link Entry} objects. A bucket holds a lone Entry directly; because
+ * Entries are shared between maps, they cannot form a linked list, so colliding entries share a
+ * fixed-size {@link BucketGroup}, chained when one fills. Removals are assumed rare, so groups are
+ * never consolidated, but an emptied group is unlinked from its chain.
*
* Stateless: each operation reports what it did through its return value -- put returns the
- * entry it replaced, remove the entry it removed, merge how much the map grew -- and the calling
- * map keeps its own count. Read-through to a parent map and its tombstones stay with the map.
+ * entry it replaced, remove the entry it removed -- and the calling store keeps its own count.
+ * Copies share Entries and clone only BucketGroup chains. Iteration skips tombstones ({@link
+ * Entry#tombstone}), which only an id store holds.
*/
final class HashSupport {
private HashSupport() {}
/** A fresh bucket array; its length must be a power of 2 for bucket masking. */
static Object[] newBuckets() {
- // needs to be a power of 2 for bucket masking calculation to work as intended
return new Object[1 << 4];
}
- /** The entry for {@code (hash, tag)} in {@code buckets}, or null. */
+ /** The entry for {@code (hash, tag)}, tombstone or not, or null. */
static Entry find(Object[] buckets, int hash, String tag) {
return findInBucket(buckets[hash & (buckets.length - 1)], hash, tag);
}
/**
- * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null).
+ * {@link #find} for a known tag, matching on its id rather than its name, so an id store never
+ * needs a name. {@code hash} is {@code Entry.bucketHash(tagId)}.
*/
+ static Entry findById(Object[] buckets, int hash, long tagId) {
+ Object bucket = buckets[hash & (buckets.length - 1)];
+ if (bucket instanceof Entry) {
+ Entry tagEntry = (Entry) bucket;
+ return tagEntry.tagHash == tagId ? tagEntry : null;
+ } else if (bucket instanceof BucketGroup) {
+ return ((BucketGroup) bucket).findInChainById(hash, tagId);
+ }
+ return null;
+ }
+
+ /** Finds an entry by hash and tag in one bucket: an Entry, a BucketGroup chain, or null. */
static Entry findInBucket(Object bucket, int hash, String tag) {
if (bucket instanceof Entry) {
Entry tagEntry = (Entry) bucket;
- return tagEntry.matches(tag) ? tagEntry : null;
+ return tagEntry.hash() == hash && tagEntry.matches(tag) ? tagEntry : null;
} else if (bucket instanceof BucketGroup) {
return ((BucketGroup) bucket).findInChain(hash, tag);
}
@@ -58,7 +73,7 @@ static Entry put(Object[] buckets, Entry newEntry) {
return null;
} else if (bucket instanceof Entry) {
Entry existingEntry = (Entry) bucket;
- if (existingEntry.matches(newEntry.tag)) {
+ if (existingEntry.hash() == newHash && existingEntry.matches(newEntry.tag)) {
thisBuckets[bucketIndex] = newEntry;
// replaced existing entry - no size change
@@ -91,14 +106,13 @@ static Entry put(Object[] buckets, Entry newEntry) {
/** Removes the entry for {@code (hash, tag)}, returning it, or null if there was none. */
static Entry remove(Object[] buckets, int hash, String tag) {
Object[] thisBuckets = buckets;
-
int bucketIndex = hash & (thisBuckets.length - 1);
Object bucket = thisBuckets[bucketIndex];
// null bucket case - do nothing
if (bucket instanceof Entry) {
Entry existingEntry = (Entry) bucket;
- if (existingEntry.matches(tag)) {
+ if (existingEntry.hash() == hash && existingEntry.matches(tag)) {
thisBuckets[bucketIndex] = null;
return existingEntry;
@@ -128,6 +142,7 @@ static Entry remove(Object[] buckets, int hash, String tag) {
* cloning only BucketGroup chains.
*/
static void copyInto(Object[] thisBuckets, Object[] thatBuckets) {
+
// Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check
// elimination
for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) {
@@ -149,12 +164,13 @@ static void copyInto(Object[] thisBuckets, Object[] thatBuckets) {
/**
* Merges {@code thatBuckets} into {@code thisBuckets}, sharing Entry objects and cloning only
- * BucketGroup chains; incoming entries replace existing ones. Returns how much the map grew.
+ * BucketGroup chains; incoming entries replace existing ones. Returns how many it added. Both
+ * arrays must be the same length (stores don't expand, so buckets are perfectly aligned).
*/
static int merge(Object[] thisBuckets, Object[] thatBuckets) {
int added = 0;
- // Since TagMap-s don't support expansion, buckets are perfectly aligned
+ // Stores don't expand, so buckets are perfectly aligned
// Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check
// elimination
for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) {
@@ -211,7 +227,7 @@ static int merge(Object[] thisBuckets, Object[] thatBuckets) {
Entry incomingEntry = thisNewGroup.findInChain(thisHash, thisEntry.tag());
if (incomingEntry != null) {
// there's already an entry w/ the same tag from the incoming TagMap
- // incoming entry clobbers the existing try, so we're done
+ // incoming entry clobbers the existing entry, so we're done
thisBuckets[i] = thisNewGroup;
// overlapping group - subtract one for clobbered existing entry
@@ -220,13 +236,13 @@ static int merge(Object[] thisBuckets, Object[] thatBuckets) {
// able to add thisEntry into the existing groups
thisBuckets[i] = thisNewGroup;
- // non overlapping group - existing entry already accounted for in this.size
+ // non overlapping group - existing entry already counted
added += thisNewGroupSize;
} else {
// unable to add into the existing groups
thisBuckets[i] = new BucketGroup(thisHash, thisEntry, thisNewGroup);
- // non overlapping group - existing entry already accounted for in this.size
+ // non overlapping group - existing entry already counted
added += thisNewGroupSize;
}
}
@@ -265,141 +281,81 @@ static int merge(Object[] thisBuckets, Object[] thatBuckets) {
return added;
}
- /** Visits every entry in {@code buckets}. */
+ /** Visits every entry, skipping tombstones. */
static void forEach(Object[] buckets, Consumer super EntryReader> consumer) {
- Object[] thisBuckets = buckets;
-
- for (int i = 0; i < thisBuckets.length; ++i) {
- Object thisBucket = thisBuckets[i];
-
- if (thisBucket instanceof Entry) {
- Entry thisEntry = (Entry) thisBucket;
-
- consumer.accept(thisEntry);
- } else if (thisBucket instanceof BucketGroup) {
- BucketGroup thisGroup = (BucketGroup) thisBucket;
-
- thisGroup.forEachInChain(consumer);
+ for (int i = 0; i < buckets.length; ++i) {
+ Object bucket = buckets[i];
+ if (bucket instanceof Entry) {
+ Entry entry = (Entry) bucket;
+ if (!entry.tombstone) consumer.accept(entry);
+ } else if (bucket instanceof BucketGroup) {
+ for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) {
+ for (int j = 0; j < BucketGroup.LEN; ++j) {
+ Entry entry = group._entryAt(j);
+ if (entry != null && !entry.tombstone) consumer.accept(entry);
+ }
+ }
}
}
}
- /** {@link #forEach(Object[], Consumer)} with a context object for a non-capturing consumer. */
static void forEach(
Object[] buckets, T thisObj, BiConsumer consumer) {
- Object[] thisBuckets = buckets;
-
- for (int i = 0; i < thisBuckets.length; ++i) {
- Object thisBucket = thisBuckets[i];
-
- if (thisBucket instanceof Entry) {
- Entry thisEntry = (Entry) thisBucket;
-
- consumer.accept(thisObj, thisEntry);
- } else if (thisBucket instanceof BucketGroup) {
- BucketGroup thisGroup = (BucketGroup) thisBucket;
-
- thisGroup.forEachInChain(thisObj, consumer);
+ for (int i = 0; i < buckets.length; ++i) {
+ Object bucket = buckets[i];
+ if (bucket instanceof Entry) {
+ Entry entry = (Entry) bucket;
+ if (!entry.tombstone) consumer.accept(thisObj, entry);
+ } else if (bucket instanceof BucketGroup) {
+ for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) {
+ for (int j = 0; j < BucketGroup.LEN; ++j) {
+ Entry entry = group._entryAt(j);
+ if (entry != null && !entry.tombstone) consumer.accept(thisObj, entry);
+ }
+ }
}
}
}
- /** {@link #forEach(Object[], Consumer)} with two context objects. */
static void forEach(
Object[] buckets, T thisObj, U otherObj, TriConsumer consumer) {
- Object[] thisBuckets = buckets;
-
- for (int i = 0; i < thisBuckets.length; ++i) {
- Object thisBucket = thisBuckets[i];
-
- if (thisBucket instanceof Entry) {
- Entry thisEntry = (Entry) thisBucket;
-
- consumer.accept(thisObj, otherObj, thisEntry);
- } else if (thisBucket instanceof BucketGroup) {
- BucketGroup thisGroup = (BucketGroup) thisBucket;
-
- thisGroup.forEachInChain(thisObj, otherObj, consumer);
+ for (int i = 0; i < buckets.length; ++i) {
+ Object bucket = buckets[i];
+ if (bucket instanceof Entry) {
+ Entry entry = (Entry) bucket;
+ if (!entry.tombstone) consumer.accept(thisObj, otherObj, entry);
+ } else if (bucket instanceof BucketGroup) {
+ for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) {
+ for (int j = 0; j < BucketGroup.LEN; ++j) {
+ Entry entry = group._entryAt(j);
+ if (entry != null && !entry.tombstone) consumer.accept(thisObj, otherObj, entry);
+ }
+ }
}
}
}
- /** Puts every entry in {@code buckets} into {@code map}, by tag, as its object value. */
static void fillMap(Object[] buckets, Map super String, Object> map) {
- Object[] thisBuckets = buckets;
-
- for (int i = 0; i < thisBuckets.length; ++i) {
- Object thisBucket = thisBuckets[i];
-
- if (thisBucket instanceof Entry) {
- Entry thisEntry = (Entry) thisBucket;
-
- map.put(thisEntry.tag, thisEntry.objectValue());
- } else if (thisBucket instanceof BucketGroup) {
- BucketGroup thisGroup = (BucketGroup) thisBucket;
-
- thisGroup.fillMapFromChain(map);
- }
- }
+ forEach(buckets, map, (m, entry) -> m.put(entry.tag(), entry.objectValue()));
}
- /** Puts every entry in {@code buckets} into {@code stringMap}, by tag, as its string value. */
- static void fillStringMap(Object[] buckets, Map super String, ? super String> stringMap) {
- Object[] thisBuckets = buckets;
-
- for (int i = 0; i < thisBuckets.length; ++i) {
- Object thisBucket = thisBuckets[i];
-
- if (thisBucket instanceof Entry) {
- Entry thisEntry = (Entry) thisBucket;
-
- stringMap.put(thisEntry.tag, thisEntry.stringValue());
- } else if (thisBucket instanceof BucketGroup) {
- BucketGroup thisGroup = (BucketGroup) thisBucket;
-
- thisGroup.fillStringMapFromChain(stringMap);
- }
- }
+ static void fillStringMap(Object[] buckets, Map super String, ? super String> map) {
+ forEach(buckets, map, (m, entry) -> m.put(entry.tag(), entry.stringValue()));
}
- /** The number of entries in {@code buckets}, counted. */
+ /** The number of live entries, not counting tombstones. Tests only. */
static int count(Object[] buckets) {
- Object[] thisBuckets = buckets;
-
- int size = 0;
- for (int i = 0; i < thisBuckets.length; ++i) {
- Object curBucket = thisBuckets[i];
-
- if (curBucket instanceof Entry) {
- size += 1;
- } else if (curBucket instanceof BucketGroup) {
- BucketGroup curGroup = (BucketGroup) curBucket;
- size += curGroup.sizeInChain();
- }
- }
- return size;
+ int[] count = new int[1];
+ forEach(buckets, count, (c, entry) -> c[0]++);
+ return count[0];
}
- /** Whether {@code buckets} holds no entry, checked bucket by bucket. */
- static boolean isEmpty(Object[] buckets) {
- Object[] thisBuckets = buckets;
-
- for (int i = 0; i < thisBuckets.length; ++i) {
- Object curBucket = thisBuckets[i];
-
- if (curBucket instanceof Entry) {
- return false;
- } else if (curBucket instanceof BucketGroup) {
- BucketGroup curGroup = (BucketGroup) curBucket;
- if (!curGroup.isEmptyChain()) return false;
- }
- }
-
- return true;
- }
-
- /** Checks that every entry in {@code buckets} sits in the bucket its hash selects. */
+ /** Throws if an entry sits in the wrong bucket. Tests only. */
static void checkBuckets(Object[] buckets) {
+ // Decided to use if ( cond ) throw new IllegalStateException rather than assert
+ // That was done to avoid the extra static initialization needed for an assertion
+ // While that's probably an unnecessary optimization, this method is only called in tests
+
Object[] thisBuckets = buckets;
for (int i = 0; i < thisBuckets.length; ++i) {
@@ -418,11 +374,11 @@ static void checkBuckets(Object[] buckets) {
for (BucketGroup curGroup = thisGroup; curGroup != null; curGroup = curGroup.prev) {
for (int j = 0; j < BucketGroup.LEN; ++j) {
- Entry thisEntry = curGroup._entryAt(i);
+ Entry thisEntry = curGroup._entryAt(j);
if (thisEntry == null) continue;
int thisHash = thisEntry.hash();
- assert curGroup._hashAt(i) == thisHash;
+ assert curGroup._hashAt(j) == thisHash;
int expectedBucket = thisHash & (thisBuckets.length - 1);
if (expectedBucket != i) {
@@ -434,7 +390,6 @@ static void checkBuckets(Object[] buckets) {
}
}
- /** The buckets' internal structure, for deep debugging. */
static String toInternalString(Object[] buckets) {
Object[] thisBuckets = buckets;
@@ -458,4 +413,72 @@ static String toInternalString(Object[] buckets) {
}
return ledger.toString();
}
+
+ /** Whether {@code buckets} holds no entry, checked bucket by bucket. */
+ static boolean isEmpty(Object[] buckets) {
+ Object[] thisBuckets = buckets;
+
+ for (int i = 0; i < thisBuckets.length; ++i) {
+ Object curBucket = thisBuckets[i];
+
+ if (curBucket instanceof Entry) {
+ return false;
+ } else if (curBucket instanceof BucketGroup) {
+ BucketGroup curGroup = (BucketGroup) curBucket;
+ if (!curGroup.isEmptyChain()) return false;
+ }
+ }
+
+ return true;
+ }
+
+ /** Walks a bucket array's raw entries, tombstones included, for an iterator. */
+ static final class BucketWalker {
+ private Object[] buckets;
+ private int bucketIndex;
+ private BucketGroup group;
+ private int groupIndex;
+
+ /** Starts over on {@code buckets}. */
+ void reset(Object[] buckets) {
+ this.buckets = buckets;
+ this.bucketIndex = -1;
+ this.group = null;
+ this.groupIndex = 0;
+ }
+
+ /** The next raw entry, or null when the array is done. */
+ Entry next() {
+ while (this.bucketIndex < this.buckets.length) {
+ if (this.group != null) {
+ for (++this.groupIndex; this.groupIndex < BucketGroup.LEN; ++this.groupIndex) {
+ Entry tagEntry = this.group._entryAt(this.groupIndex);
+ if (tagEntry != null) return tagEntry;
+ }
+
+ // done processing - that group, go to next group
+ this.group = this.group.prev;
+ this.groupIndex = -1;
+ }
+
+ // if the group is null, then we've finished the current bucket - so advance the bucket
+ if (this.group == null) {
+ for (++this.bucketIndex; this.bucketIndex < this.buckets.length; ++this.bucketIndex) {
+ Object bucket = this.buckets[this.bucketIndex];
+
+ if (bucket instanceof Entry) {
+ return (Entry) bucket;
+ } else if (bucket instanceof BucketGroup) {
+ this.group = (BucketGroup) bucket;
+ this.groupIndex = -1;
+
+ break;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+ }
}
diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java
new file mode 100644
index 00000000000..d9e2f1bdf86
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java
@@ -0,0 +1,159 @@
+package datadog.trace.api;
+
+import datadog.apicontract.Restricted;
+import datadog.trace.api.TagMap.EntryReader;
+import datadog.trace.api.function.TriConsumer;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+/**
+ * Known tags keyed by their {@link KnownTags} id. Every method takes an id, or an {@link
+ * EntryReader} for a known tag -- never a name, so nothing here can be handed a name that a caller
+ * has not yet resolved.
+ *
+ * A span in the tracer core owns its id store directly ({@link #create}); a {@link TagMap} owns
+ * one too.
+ *
+ *
{@link IdStoreFactory} builds every store, so the JVM loads one implementation and class
+ * hierarchy analysis devirtualizes every call on an {@code IdStore} without a type guard; nothing
+ * that is always loaded may extend this class. No locking: guarded by the owner's lock.
+ */
+@Restricted(allowedIn = {"datadog.trace.core"})
+public abstract class IdStore {
+ /** Visits a store's visible entries one at a time. */
+ interface Cursor {
+ /** The next visible entry, or null when there are none left. */
+ EntryReader next();
+ }
+
+ /** Throws for an id that names no known tag. */
+ static IllegalArgumentException notKnown(long tagId) {
+ return new IllegalArgumentException("not a known tag id: " + Long.toHexString(tagId));
+ }
+
+ /*
+ * Point operations. A returned EntryReader is valid only until this store's next operation: a
+ * store that keeps known tags without an Entry per tag answers with a reused flyweight. Call
+ * EntryReader.entry() to keep one. Stores are inside the tracer's trust boundary, so callers are
+ * expected to follow this rather than have it enforced.
+ */
+
+ /**
+ * The entry for the known tag {@code tagId}, reading through to the parent chain, or null.
+ *
+ * @throws IllegalArgumentException if {@code tagId} names no known tag
+ */
+ public abstract EntryReader getEntryReader(long tagId);
+
+ /**
+ * The entry for the known tag {@code tagId}, reading through to the parent chain, or null. Unlike
+ * {@link #getEntryReader}, the result is an {@link TagMap.Entry} the caller may keep, and it
+ * shares no state with the store, so it is safe to call concurrently on a frozen store that
+ * several threads read, such as a span prototype's or the tracer-level tags.
+ *
+ * @throws IllegalArgumentException if {@code tagId} names no known tag
+ */
+ public abstract TagMap.Entry getEntry(long tagId);
+
+ /**
+ * {@link #getEntryReader}'s value, boxed if necessary, or null -- for a caller that needs only
+ * the value, so a store that keeps known tags without an Entry per tag can answer without one.
+ */
+ public abstract Object getValue(long tagId);
+
+ /**
+ * {@link #getValue} as a String, or null. A store that keeps an Entry per tag answers with the
+ * Entry's cached string, so a numeric tag read repeatedly, as the serializer does, is formatted
+ * once.
+ */
+ public abstract String getString(long tagId);
+
+ /*
+ * Sets a known tag without validating its id: the caller has checked KnownTagCodec.isKnown.
+ */
+
+ public abstract void set(long tagId, Object value);
+
+ public abstract void set(long tagId, CharSequence value);
+
+ public abstract void set(long tagId, boolean value);
+
+ public abstract void set(long tagId, int value);
+
+ public abstract void set(long tagId, long value);
+
+ public abstract void set(long tagId, float value);
+
+ public abstract void set(long tagId, double value);
+
+ /**
+ * Inserts or replaces a known tag, returning the local entry it replaced (or null). Does not
+ * consult the read-through parent. A store that keeps Entry objects keeps {@code entryReader}'s
+ * Entry (free when it is one); a store that doesn't copies its value.
+ */
+ public abstract EntryReader put(EntryReader entryReader);
+
+ /** {@link #put}, returning the prior visible entry -- local, else read through. */
+ public abstract EntryReader getAndPut(EntryReader entryReader);
+
+ /**
+ * Removes the known tag {@code tagId}, returning the prior visible entry; an inherited one is
+ * hidden by a tombstone.
+ *
+ * @throws IllegalArgumentException if {@code tagId} names no known tag
+ */
+ public abstract EntryReader remove(long tagId);
+
+ /*
+ * Whole-store operations, for the owning TagMap.
+ */
+
+ /** Exact count, including the visible part of the read-through chain. */
+ abstract int size();
+
+ abstract boolean isEmpty();
+
+ /** Cheap: no level of the chain holds a local entry (ignores shadowing and tombstones). */
+ abstract boolean isDefinitelyEmpty();
+
+ /** Upper bound: the local counts of every level. */
+ abstract int estimateSize();
+
+ /** Whether this store reads through to a parent. */
+ abstract boolean hasParent();
+
+ /** Visits the local entries, then the visible inherited ones. */
+ abstract void forEach(Consumer super EntryReader> consumer);
+
+ abstract void forEach(T thisObj, BiConsumer consumer);
+
+ abstract void forEach(
+ T thisObj, U otherObj, TriConsumer consumer);
+
+ /**
+ * A cursor over the visible entries, local then inherited, for an iterator. Each entry it returns
+ * is valid until its next call, as with a point read.
+ */
+ abstract Cursor cursor();
+
+ /** An independently mutable copy with the same parent, sharing any Entry objects it holds. */
+ abstract IdStore copy();
+
+ /** Copies the entries of the parentless store {@code that}, sharing any Entry objects. */
+ abstract void putAllLocal(IdStore that);
+
+ /** Puts the local entries into {@code map}, for a store with no parent. */
+ abstract void fillMapLocal(Map super String, Object> map);
+
+ abstract void fillStringMapLocal(Map super String, ? super String> map);
+
+ /** Tests only: throws if the store's structure is inconsistent. */
+ abstract void checkIntegrity();
+
+ /** Tests only: the local count, recomputed from the structure. */
+ abstract int computeSize();
+
+ /** For deep debugging. */
+ abstract String toInternalString();
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java
new file mode 100644
index 00000000000..caf9a2933ee
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java
@@ -0,0 +1,40 @@
+package datadog.trace.api;
+
+/**
+ * Builds the {@link IdStore}s of the one implementation this JVM uses, today {@link HashIdStore}.
+ *
+ * Every store is built through {@link Default#INSTANCE}, never with {@code new}, so a JVM loads
+ * one {@code IdStore} subclass and class hierarchy analysis devirtualizes every call on an {@code
+ * IdStore} without a guard. A store builds its own copies.
+ *
+ *
No static state here, and no {@link TagMap}: {@link TagMap#EMPTY} is built with {@link
+ * #createEmpty}, so class initialization works whichever of the two starts first.
+ */
+abstract class IdStoreFactory {
+ /** The JVM's store. */
+ static final class Default {
+ static final IdStoreFactory INSTANCE = HashIdStoreFactory.INSTANCE;
+ }
+
+ /** A fresh store reading through to {@code parent} (may be null). */
+ abstract IdStore create(IdStore parent);
+
+ /** A new empty parentless store, for a frozen map. */
+ abstract IdStore createEmpty();
+}
+
+final class HashIdStoreFactory extends IdStoreFactory {
+ static final HashIdStoreFactory INSTANCE = new HashIdStoreFactory();
+
+ private HashIdStoreFactory() {}
+
+ @Override
+ IdStore create(IdStore parent) {
+ return new HashIdStore(parent);
+ }
+
+ @Override
+ IdStore createEmpty() {
+ return HashIdStore.createEmpty();
+ }
+}
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 8cbc4358c9c..2a476c41c4c 100644
--- a/internal-api/src/main/java/datadog/trace/api/TagMap.java
+++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java
@@ -159,6 +159,13 @@ public boolean isRemoval() {
}
}
+ /**
+ * A momentary view of one tag: read it where it was obtained and drop it. A reader MUST NOT be
+ * kept past the call that produced it -- not in a field, a collection or a later step. A store
+ * may answer with a flyweight it reuses for the next read or write, so a kept reader can later
+ * report another tag's value. To keep a tag, call {@link #entry()}: an {@link Entry} is the
+ * keepable form. (To be marked {@code @ScopeBounded}, APMLP-2086.)
+ */
public interface EntryReader {
public static final byte OBJECT = 1;
@@ -448,6 +455,41 @@ static Entry newDoubleEntry(String tag, Double box) {
volatile String strCache = null;
+ /**
+ * True for a known tag's tombstone: the marker the hash id store puts in its own buckets to
+ * hide an inherited entry it has removed. Never visible outside the store; see {@link
+ * #tombstoneOf}.
+ */
+ final boolean tombstone;
+
+ /** The shared tombstone per known tag, by serial, created on first use. */
+ private static final Entry[] TOMBSTONES = new Entry[KnownTagCodec.serialLimit()];
+
+ /**
+ * The tombstone for the known tag {@code tagId}: one shared, immutable entry per tag, so
+ * tombstoning a known tag allocates nothing after the first time. Racing first uses may each
+ * build one; either serves, since a tombstone is told apart by {@link #tombstone}, not
+ * identity.
+ */
+ static Entry tombstoneOf(long tagId) {
+ int serial = KnownTagCodec.serialNum(tagId);
+ Entry tombstone = TOMBSTONES[serial];
+ if (tombstone == null || tombstone.tagHash != tagId) {
+ tombstone = new Entry(tagId);
+ TOMBSTONES[serial] = tombstone;
+ }
+ return tombstone;
+ }
+
+ private Entry(long tagId) {
+ super(requireKnownName(tagId));
+ this.tagHash = tagId;
+ this.tombstone = true;
+ this.rawType = OBJECT;
+ this.rawPrim = 0L;
+ this.rawObj = null;
+ }
+
/**
* A custom tag's entry: no registry lookup. A known tag is built from its id instead -- callers
* that start from a name resolve it once and pick the id or name factory (see {@code
@@ -462,6 +504,7 @@ private Entry(String tag, byte type, long prim, Object obj) {
// Resolve the name once: it both validates the id and names the entry.
super(requireKnownName(tagId));
this.tagHash = tagId;
+ this.tombstone = false;
this.rawType = type;
this.rawPrim = prim;
@@ -472,6 +515,7 @@ private Entry(String tag, byte type, long prim, Object obj) {
Entry(long tagId, String customTag, byte type, long prim, Object obj) {
super(tagId != 0 ? KnownTagCodec.nameOf(tagId) : customTag);
this.tagHash = tagId != 0 ? tagId : customHash(customTag);
+ this.tombstone = false;
this.rawType = type;
this.rawPrim = prim;
@@ -2498,6 +2542,31 @@ Entry findInChain(int hash, String tag) {
return null;
}
+ /**
+ * {@link #findInChain} for a known tag: matches on the entry's id, so it needs no name. Only an
+ * entry stored under {@code tagId} has that id, and the hash check filters first.
+ */
+ Entry findInChainById(int hash, long tagId) {
+ for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) {
+ Entry curEntry = curGroup._findById(hash, tagId);
+ if (curEntry != null) return curEntry;
+ }
+ return null;
+ }
+
+ Entry _findById(int hash, long tagId) {
+ if (this.hash0 == hash && this.entry0.tagHash == tagId) {
+ return this.entry0;
+ } else if (this.hash1 == hash && this.entry1.tagHash == tagId) {
+ return this.entry1;
+ } else if (this.hash2 == hash && this.entry2.tagHash == tagId) {
+ return this.entry2;
+ } else if (this.hash3 == hash && this.entry3.tagHash == tagId) {
+ return this.entry3;
+ }
+ return null;
+ }
+
Entry _find(int hash, String tag) {
// if ( this._mayContain(hash) ) return null;
@@ -2687,86 +2756,6 @@ Entry _remove(int hash, String tag) {
return existingEntry;
}
- void forEachInChain(Consumer super EntryReader> consumer) {
- for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) {
- curGroup._forEach(consumer);
- }
- }
-
- void _forEach(Consumer super EntryReader> consumer) {
- if (this.entry0 != null) consumer.accept(this.entry0);
- if (this.entry1 != null) consumer.accept(this.entry1);
- if (this.entry2 != null) consumer.accept(this.entry2);
- if (this.entry3 != null) consumer.accept(this.entry3);
- }
-
- void forEachInChain(T thisObj, BiConsumer consumer) {
- for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) {
- curGroup._forEach(thisObj, consumer);
- }
- }
-
- void _forEach(T thisObj, BiConsumer consumer) {
- if (this.entry0 != null) consumer.accept(thisObj, this.entry0);
- if (this.entry1 != null) consumer.accept(thisObj, this.entry1);
- if (this.entry2 != null) consumer.accept(thisObj, this.entry2);
- if (this.entry3 != null) consumer.accept(thisObj, this.entry3);
- }
-
- void forEachInChain(
- T thisObj, U otherObj, TriConsumer consumer) {
- for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) {
- curGroup._forEach(thisObj, otherObj, consumer);
- }
- }
-
- void _forEach(T thisObj, U otherObj, TriConsumer consumer) {
- if (this.entry0 != null) consumer.accept(thisObj, otherObj, this.entry0);
- if (this.entry1 != null) consumer.accept(thisObj, otherObj, this.entry1);
- if (this.entry2 != null) consumer.accept(thisObj, otherObj, this.entry2);
- if (this.entry3 != null) consumer.accept(thisObj, otherObj, this.entry3);
- }
-
- void fillMapFromChain(Map super String, ? super Object> map) {
- for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) {
- curGroup._fillMap(map);
- }
- }
-
- void _fillMap(Map super String, ? super Object> map) {
- Entry entry0 = this.entry0;
- if (entry0 != null) map.put(entry0.tag, entry0.objectValue());
-
- Entry entry1 = this.entry1;
- if (entry1 != null) map.put(entry1.tag, entry1.objectValue());
-
- Entry entry2 = this.entry2;
- if (entry2 != null) map.put(entry2.tag, entry2.objectValue());
-
- Entry entry3 = this.entry3;
- if (entry3 != null) map.put(entry3.tag, entry3.objectValue());
- }
-
- void fillStringMapFromChain(Map super String, ? super String> map) {
- for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) {
- curGroup._fillStringMap(map);
- }
- }
-
- void _fillStringMap(Map super String, ? super String> map) {
- Entry entry0 = this.entry0;
- if (entry0 != null) map.put(entry0.tag, entry0.stringValue());
-
- Entry entry1 = this.entry1;
- if (entry1 != null) map.put(entry1.tag, entry1.stringValue());
-
- Entry entry2 = this.entry2;
- if (entry2 != null) map.put(entry2.tag, entry2.stringValue());
-
- Entry entry3 = this.entry3;
- if (entry3 != null) map.put(entry3.tag, entry3.stringValue());
- }
-
BucketGroup cloneChain() {
BucketGroup thisClone = this._cloneEntries();
@@ -2791,15 +2780,15 @@ BucketGroup _cloneEntries() {
@Override
public String toString() {
- StringBuilder ledger = new StringBuilder(32);
- ledger.append('[');
+ StringBuilder builder = new StringBuilder(32);
+ builder.append('[');
for (int i = 0; i < BucketGroup.LEN; ++i) {
- if (i != 0) ledger.append(", ");
+ if (i != 0) builder.append(", ");
- ledger.append(this._entryAt(i));
+ builder.append(this._entryAt(i));
}
- ledger.append(']');
- return ledger.toString();
+ builder.append(']');
+ return builder.toString();
}
}
diff --git a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java
new file mode 100644
index 00000000000..9662c577240
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java
@@ -0,0 +1,541 @@
+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 static org.junit.jupiter.api.Named.named;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import datadog.trace.api.TagMap.EntryReader;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * The {@link IdStore} contract, run against both stores in the normal test JVM: the dense store is
+ * behind a flag that is off by default, so the shared {@link TagMap} suites only reach the hash
+ * store. Each store is built from a parent of its own kind, as the factories do.
+ */
+class IdStoreContractTest {
+
+ static Stream stores() {
+ return Stream.of(
+ arguments(named("HashIdStore", (Function) HashIdStore::new)));
+ }
+
+ /** Every known tag id, in declaration order. */
+ private static List knownIds() throws IllegalAccessException {
+ List ids = new ArrayList<>();
+ for (java.lang.reflect.Field field : KnownTags.class.getFields()) {
+ if (field.getType() == long.class) {
+ long id = field.getLong(null);
+ if (KnownTagCodec.isKnown(id) && !ids.contains(id)) {
+ ids.add(id);
+ }
+ }
+ }
+ return ids;
+ }
+
+ private static Map visible(IdStore store) {
+ Map seen = new HashMap<>();
+ store.forEach(entry -> assertNull(seen.put(entry.tag(), entry.objectValue()), entry.tag()));
+ return seen;
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void typedSettersRoundTripThroughTheReader(Function newStore) {
+ IdStore store = newStore.apply(null);
+ store.set(KnownTags.HTTP_ROUTE_ID, (Object) "/users");
+ store.set(KnownTags.HTTP_METHOD_ID, (CharSequence) "GET");
+ store.set(KnownTags.ERROR_ID, true);
+ store.set(KnownTags.HTTP_STATUS_CODE_ID, 200);
+ store.set(KnownTags.PEER_PORT_ID, 5432L);
+ store.set(KnownTags.DD_AGENT_PSR_ID, 0.5f);
+ store.set(KnownTags.DB_TYPE_ID, (Object) "postgres");
+ store.set(KnownTags.COMPONENT_ID, (CharSequence) "jdbc");
+ store.set(KnownTags.SPAN_KIND_ID, (Object) "client");
+ store.set(KnownTags.DB_POOL_NAME_ID, (Object) "pool");
+ store.set(KnownTags.HTTP_URL_ID, (Object) "http://x");
+ store.set(KnownTags.DB_INSTANCE_ID, 2.5d);
+
+ assertEquals("/users", store.getValue(KnownTags.HTTP_ROUTE_ID));
+ assertEquals("GET", store.getValue(KnownTags.HTTP_METHOD_ID));
+
+ EntryReader flag = store.getEntryReader(KnownTags.ERROR_ID);
+ assertTrue(flag.booleanValue());
+ assertEquals(EntryReader.BOOLEAN, flag.type());
+ assertFalse(flag.isNumber());
+
+ EntryReader status = store.getEntryReader(KnownTags.HTTP_STATUS_CODE_ID);
+ assertEquals(200, status.intValue());
+ assertEquals(200L, status.longValue());
+ assertTrue(status.is(EntryReader.INT));
+ assertTrue(status.isNumericPrimitive());
+ assertFalse(status.isObject());
+ assertEquals(KnownTags.HTTP_STATUS_CODE_ID, status.tagId());
+ assertEquals("http.status_code", status.tag());
+
+ assertEquals(5432L, store.getEntryReader(KnownTags.PEER_PORT_ID).longValue());
+ assertEquals(0.5f, store.getEntryReader(KnownTags.DD_AGENT_PSR_ID).floatValue());
+ assertEquals(2.5d, store.getEntryReader(KnownTags.DB_INSTANCE_ID).doubleValue());
+
+ EntryReader route = store.getEntryReader(KnownTags.HTTP_ROUTE_ID);
+ assertEquals("/users", route.stringValue());
+ assertTrue(route.isObject());
+ assertEquals("/users", route.entry().objectValue());
+ assertEquals("http.route", route.mapEntry().getKey());
+
+ assertEquals(12, store.size());
+ store.checkIntegrity();
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void putAndGetAndPutReturnThePriorValue(Function newStore) {
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited");
+ IdStore store = newStore.apply(parent);
+
+ assertNull(store.put(TagMap.Entry.newAnyEntry(KnownTags.HTTP_METHOD_ID, "GET")));
+ EntryReader prior = store.put(TagMap.Entry.newAnyEntry(KnownTags.HTTP_METHOD_ID, "POST"));
+ assertEquals("GET", prior.objectValue());
+
+ // getAndPut reports the prior visible value, inherited included
+ EntryReader inherited =
+ store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/local"));
+ assertEquals("/inherited", inherited.objectValue());
+ assertEquals("/local", store.getValue(KnownTags.HTTP_ROUTE_ID));
+ assertEquals(
+ "/local",
+ store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/again")).objectValue());
+ assertNull(store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.COMPONENT_ID, "okhttp")));
+ store.checkIntegrity();
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void removingAnInheritedTagHidesItUntilItIsSetAgain(Function newStore) {
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited");
+ parent.set(KnownTags.COMPONENT_ID, "okhttp");
+ IdStore store = newStore.apply(parent);
+ store.set(KnownTags.HTTP_METHOD_ID, "GET");
+ assertTrue(store.hasParent());
+ assertEquals(3, store.size());
+
+ assertEquals("/inherited", store.remove(KnownTags.HTTP_ROUTE_ID).objectValue());
+ assertNull(store.getValue(KnownTags.HTTP_ROUTE_ID));
+ assertNull(store.remove(KnownTags.HTTP_ROUTE_ID), "already hidden");
+ assertEquals(2, store.size());
+ assertEquals("/inherited", parent.getValue(KnownTags.HTTP_ROUTE_ID), "parent untouched");
+
+ // a local value that also hides an inherited one is removed with its tombstone in place
+ store.set(KnownTags.COMPONENT_ID, "local");
+ assertEquals("local", store.remove(KnownTags.COMPONENT_ID).objectValue());
+ assertNull(store.getValue(KnownTags.COMPONENT_ID));
+
+ assertEquals("GET", store.remove(KnownTags.HTTP_METHOD_ID).objectValue());
+ assertNull(store.remove(KnownTags.HTTP_METHOD_ID), "absent");
+ assertTrue(store.isEmpty());
+ assertTrue(store.isDefinitelyEmpty() || store.estimateSize() >= 0);
+
+ // setting a hidden tag again revives it
+ store.set(KnownTags.HTTP_ROUTE_ID, "/revived");
+ assertEquals("/revived", store.getValue(KnownTags.HTTP_ROUTE_ID));
+ assertEquals(1, store.size());
+ assertFalse(store.isEmpty());
+ store.checkIntegrity();
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void sizeAndEmptinessCountWhatIsVisible(Function newStore) {
+ IdStore empty = newStore.apply(null);
+ assertTrue(empty.isEmpty());
+ assertTrue(empty.isDefinitelyEmpty());
+ assertEquals(0, empty.estimateSize());
+ assertFalse(empty.hasParent());
+
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/a");
+ parent.set(KnownTags.HTTP_METHOD_ID, "GET");
+ IdStore child = newStore.apply(parent);
+ assertFalse(child.isEmpty(), "inherits visible tags");
+ assertFalse(child.isDefinitelyEmpty());
+
+ child.set(KnownTags.HTTP_METHOD_ID, "POST"); // shadows
+ child.set(KnownTags.COMPONENT_ID, "okhttp");
+ assertEquals(3, child.size());
+ assertTrue(child.estimateSize() >= 3);
+
+ child.remove(KnownTags.HTTP_ROUTE_ID);
+ child.remove(KnownTags.HTTP_METHOD_ID);
+ child.remove(KnownTags.COMPONENT_ID);
+ assertTrue(child.isEmpty(), "every inherited tag hidden");
+ assertEquals(0, child.size());
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void everyIterationSeesEachVisibleTagOnce(Function newStore) {
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/a");
+ parent.set(KnownTags.HTTP_METHOD_ID, "GET");
+ parent.set(KnownTags.COMPONENT_ID, "okhttp");
+ IdStore store = newStore.apply(parent);
+ store.set(KnownTags.HTTP_METHOD_ID, "POST"); // shadows the parent's
+ store.set(KnownTags.SPAN_KIND_ID, "client");
+ store.remove(KnownTags.COMPONENT_ID); // hides the parent's
+
+ Map expected = new HashMap<>();
+ expected.put("http.route", "/a");
+ expected.put("http.method", "POST");
+ expected.put("span.kind", "client");
+ assertEquals(expected, visible(store));
+
+ Map viaContext = new HashMap<>();
+ store.forEach(viaContext, (map, entry) -> map.put(entry.tag(), entry.objectValue()));
+ assertEquals(expected, viaContext);
+
+ Map viaTwoContexts = new HashMap<>();
+ store.forEach(
+ viaTwoContexts, "-", (map, suffix, entry) -> map.put(entry.tag(), entry.objectValue()));
+ assertEquals(expected, viaTwoContexts);
+
+ Map viaCursor = new HashMap<>();
+ IdStore.Cursor cursor = store.cursor();
+ for (EntryReader entry = cursor.next(); entry != null; entry = cursor.next()) {
+ assertNull(viaCursor.put(entry.tag(), entry.objectValue()), entry.tag());
+ }
+ assertEquals(expected, viaCursor);
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void aCopyIsIndependent(Function newStore) {
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/a");
+ IdStore store = newStore.apply(parent);
+ store.set(KnownTags.HTTP_METHOD_ID, "GET");
+ store.remove(KnownTags.HTTP_ROUTE_ID);
+
+ IdStore copy = store.copy();
+ copy.set(KnownTags.HTTP_METHOD_ID, "POST");
+ copy.set(KnownTags.HTTP_ROUTE_ID, "/copy");
+
+ assertEquals("GET", store.getValue(KnownTags.HTTP_METHOD_ID));
+ assertNull(store.getValue(KnownTags.HTTP_ROUTE_ID), "the original keeps its tombstone");
+ assertEquals("POST", copy.getValue(KnownTags.HTTP_METHOD_ID));
+ assertEquals("/copy", copy.getValue(KnownTags.HTTP_ROUTE_ID));
+ copy.checkIntegrity();
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void putAllLocalMergesAnotherStoresTags(Function newStore) {
+ IdStore source = newStore.apply(null);
+ source.set(KnownTags.HTTP_ROUTE_ID, "/a");
+ source.set(KnownTags.HTTP_METHOD_ID, "GET");
+
+ IdStore empty = newStore.apply(null);
+ empty.putAllLocal(source); // nothing here yet
+ assertEquals(visible(source), visible(empty));
+
+ IdStore merged = newStore.apply(null);
+ merged.set(KnownTags.HTTP_METHOD_ID, "POST");
+ merged.set(KnownTags.COMPONENT_ID, "okhttp");
+ merged.putAllLocal(source);
+ assertEquals("GET", merged.getValue(KnownTags.HTTP_METHOD_ID), "the source wins");
+ assertEquals("okhttp", merged.getValue(KnownTags.COMPONENT_ID));
+ assertEquals(3, merged.size());
+
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited");
+ IdStore tombstoned = newStore.apply(parent);
+ tombstoned.remove(KnownTags.HTTP_ROUTE_ID);
+ tombstoned.putAllLocal(source); // replaces the tombstone
+ assertEquals("/a", tombstoned.getValue(KnownTags.HTTP_ROUTE_ID));
+ assertEquals(2, tombstoned.size());
+
+ newStore.apply(null).putAllLocal(newStore.apply(null)); // an empty source is a no-op
+ merged.checkIntegrity();
+ tombstoned.checkIntegrity();
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void fillsCopyTheLocalTags(Function newStore) {
+ IdStore store = newStore.apply(null);
+ store.set(KnownTags.HTTP_ROUTE_ID, "/a");
+ store.set(KnownTags.HTTP_STATUS_CODE_ID, 200);
+
+ Map map = new HashMap<>();
+ store.fillMapLocal(map);
+ assertEquals("/a", map.get("http.route"));
+ assertEquals(200, map.get("http.status_code"));
+
+ Map strings = new HashMap<>();
+ store.fillStringMapLocal(strings);
+ assertEquals("200", strings.get("http.status_code"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void growsPastItsFirstCapacity(Function newStore) throws Exception {
+ IdStore store = newStore.apply(null);
+ List ids = knownIds();
+ int n = Math.min(40, ids.size());
+ for (int i = 0; i < n; ++i) {
+ store.set(ids.get(i), "v" + i);
+ }
+ for (int i = 0; i < n; ++i) {
+ assertEquals("v" + i, store.getValue(ids.get(i)));
+ }
+ assertEquals(n, store.size());
+ assertTrue(store.computeSize() > 0);
+ assertNotNull(store.toInternalString());
+ store.checkIntegrity();
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void putAllLocalMergesOverlappingStoresWithCollidingTags(Function newStore)
+ throws Exception {
+ // enough tags that buckets hold groups of colliding entries on both sides of the merge
+ List ids = knownIds();
+ IdStore target = newStore.apply(null);
+ IdStore source = newStore.apply(null);
+ for (int i = 0; i < 30; ++i) {
+ target.set(ids.get(i), "target" + i);
+ }
+ for (int i = 20; i < 50; ++i) {
+ source.set(ids.get(i), "source" + i);
+ }
+ target.putAllLocal(source);
+ assertEquals(50, target.size());
+ for (int i = 0; i < 50; ++i) {
+ assertEquals(i < 20 ? "target" + i : "source" + i, target.getValue(ids.get(i)));
+ }
+ target.checkIntegrity();
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void getAndPutRevivesARemovedInheritedTag(Function newStore) {
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited");
+ IdStore store = newStore.apply(parent);
+ store.remove(KnownTags.HTTP_ROUTE_ID);
+
+ // hidden, so there is no visible prior value
+ assertNull(store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/revived")));
+ assertEquals("/revived", store.getValue(KnownTags.HTTP_ROUTE_ID));
+ assertEquals(1, store.size());
+ store.checkIntegrity();
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void readsGoThroughEveryAncestor(Function newStore) {
+ IdStore grandparent = newStore.apply(null);
+ grandparent.set(KnownTags.HTTP_ROUTE_ID, "/grandparent");
+ grandparent.set(KnownTags.COMPONENT_ID, "okhttp");
+ IdStore parent = newStore.apply(grandparent);
+ parent.set(KnownTags.HTTP_METHOD_ID, "GET");
+ parent.remove(KnownTags.COMPONENT_ID);
+ IdStore store = newStore.apply(parent);
+
+ assertEquals("/grandparent", store.getValue(KnownTags.HTTP_ROUTE_ID));
+ assertEquals("GET", store.getValue(KnownTags.HTTP_METHOD_ID));
+ assertNull(store.getValue(KnownTags.COMPONENT_ID), "hidden by the parent");
+ assertEquals(2, store.size());
+ Map expected = new HashMap<>();
+ expected.put("http.route", "/grandparent");
+ expected.put("http.method", "GET");
+ assertEquals(expected, visible(store));
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void getStringFormatsTheVisibleValue(Function newStore) {
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited");
+ parent.set(KnownTags.COMPONENT_ID, "hidden");
+ IdStore store = newStore.apply(parent);
+ store.set(KnownTags.PEER_PORT_ID, 5432);
+ store.set(KnownTags.DB_INSTANCE_ID, 1.5d);
+ store.remove(KnownTags.COMPONENT_ID);
+
+ assertEquals("5432", store.getString(KnownTags.PEER_PORT_ID));
+ assertEquals("1.5", store.getString(KnownTags.DB_INSTANCE_ID));
+ assertEquals("/inherited", store.getString(KnownTags.HTTP_ROUTE_ID));
+ assertNull(store.getString(KnownTags.COMPONENT_ID), "hidden by the removal");
+ assertNull(store.getString(KnownTags.HTTP_METHOD_ID));
+ assertThrows(
+ IllegalArgumentException.class, () -> store.getString(KnownTagCodec.makeTagId(9999)));
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void settingANullValueRemovesTheTag(Function newStore) {
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited");
+ IdStore store = newStore.apply(parent);
+ store.set(KnownTags.HTTP_METHOD_ID, "GET");
+
+ store.set(KnownTags.HTTP_METHOD_ID, (Object) null);
+ store.set(KnownTags.HTTP_ROUTE_ID, (CharSequence) null); // hides the inherited tag
+ assertNull(store.getValue(KnownTags.HTTP_METHOD_ID));
+ assertNull(store.getValue(KnownTags.HTTP_ROUTE_ID));
+ assertEquals(0, store.size());
+ assertEquals("/inherited", parent.getValue(KnownTags.HTTP_ROUTE_ID), "the parent keeps it");
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void aReaderWithNoEntryIsANoOp(Function newStore) {
+ IdStore store = newStore.apply(null);
+ store.set(KnownTags.HTTP_ROUTE_ID, "/a");
+ NoEntryReader empty = new NoEntryReader("http.route", KnownTags.HTTP_ROUTE_ID);
+
+ assertNull(store.put(empty));
+ assertNull(store.getAndPut(empty));
+ assertEquals("/a", store.getValue(KnownTags.HTTP_ROUTE_ID), "left as it was");
+ assertEquals(1, store.size());
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void getEntryReturnsAnEntryTheCallerMayKeep(Function newStore) {
+ IdStore parent = newStore.apply(null);
+ parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited");
+ IdStore store = newStore.apply(parent);
+ store.set(KnownTags.HTTP_METHOD_ID, "GET");
+
+ TagMap.Entry method = store.getEntry(KnownTags.HTTP_METHOD_ID);
+ TagMap.Entry route = store.getEntry(KnownTags.HTTP_ROUTE_ID);
+ EntryReader methodReader = store.getEntryReader(KnownTags.HTTP_METHOD_ID);
+ EntryReader routeReader = store.getEntryReader(KnownTags.HTTP_ROUTE_ID);
+
+ // kept side by side, each still holds its own tag
+ assertEquals("GET", method.objectValue());
+ assertEquals(KnownTags.HTTP_METHOD_ID, method.tagId());
+ assertEquals("/inherited", route.objectValue());
+ assertEquals("GET", methodReader.objectValue());
+ assertEquals("/inherited", routeReader.objectValue());
+ assertNull(store.getEntry(KnownTags.COMPONENT_ID));
+ assertThrows(
+ IllegalArgumentException.class, () -> store.getEntry(KnownTagCodec.makeTagId(9999)));
+ }
+
+ @ParameterizedTest
+ @MethodSource("stores")
+ void anUnknownIdIsRejected(Function newStore) {
+ IdStore store = newStore.apply(null);
+ long unknown = KnownTagCodec.makeTagId(9999);
+ assertThrows(IllegalArgumentException.class, () -> store.getValue(unknown));
+ assertThrows(IllegalArgumentException.class, () -> store.remove(unknown));
+ }
+
+ /** An EntryReader with no entry and no value, as a caller outside the tracer may pass. */
+ static final class NoEntryReader implements TagMap.EntryReader {
+ private final String tag;
+ private final long tagId;
+
+ NoEntryReader(String tag, long tagId) {
+ this.tag = tag;
+ this.tagId = tagId;
+ }
+
+ @Override
+ public String tag() {
+ return tag;
+ }
+
+ @Override
+ public long tagId() {
+ return tagId;
+ }
+
+ @Override
+ public byte type() {
+ return TagMap.EntryReader.OBJECT;
+ }
+
+ @Override
+ public boolean is(byte type) {
+ return false;
+ }
+
+ @Override
+ public boolean isNumericPrimitive() {
+ return false;
+ }
+
+ @Override
+ public boolean isNumber() {
+ return false;
+ }
+
+ @Override
+ public boolean isObject() {
+ return true;
+ }
+
+ @Override
+ public Object objectValue() {
+ return null;
+ }
+
+ @Override
+ public String stringValue() {
+ return null;
+ }
+
+ @Override
+ public boolean booleanValue() {
+ return false;
+ }
+
+ @Override
+ public int intValue() {
+ return 0;
+ }
+
+ @Override
+ public long longValue() {
+ return 0;
+ }
+
+ @Override
+ public float floatValue() {
+ return 0;
+ }
+
+ @Override
+ public double doubleValue() {
+ return 0;
+ }
+
+ @Override
+ public java.util.Map.Entry mapEntry() {
+ return null;
+ }
+
+ @Override
+ public TagMap.Entry entry() {
+ return null;
+ }
+ }
+}
diff --git a/tag-conventions.yaml b/tag-conventions.yaml
index 3f13f0fbd49..0f38f733745 100644
--- a/tag-conventions.yaml
+++ b/tag-conventions.yaml
@@ -88,6 +88,7 @@ trace_level:
- { dd-name: _dd.trace_span_attribute_schema, type: int, required: required }
- { dd-name: _dd.profiling.ctx, type: string, required: optional } # once the profiling context integration is available
- { dd-name: process_id, type: long, required: required }
+ - { dd-name: _dd.agent_psr, type: double, required: optional } # the agent-provided sampling rate the sampler applied
- { dd-name: ai_guard.event, type: boolean, required: optional } # when the trace holds an AI Guard evaluation span
span_types: