diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt index a4c0c1e997e..8234a1be385 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/KnownTagsEmitter.kt @@ -209,6 +209,21 @@ object KnownTagsEmitter { """ }; + /** + * Each tag's id, indexed by serial; slot 0 (no tag) is 0. Lets a store that keeps only the + * 16-bit serial recover the full id, flag and slot bits included. + */ + static final long[] IDS_BY_SERIAL = { + 0L, + """.trimIndent() + ) + for (t in reg.tags.sortedBy { it.serial }) { + appendLine(" ${idC(t.ddName)},") + } + appendLine( + """ + }; + /** * The registry's name↔id tables, as a {@link KnownTagCodec.Resolver}. {@code KnownTagCodec} * reads this field from its own holder, so the two classes complete each other: the codec owns @@ -221,6 +236,11 @@ object KnownTagsEmitter { int serial = KnownTagCodec.serialNum(tagId); return serial < NAMES_BY_SERIAL.length ? NAMES_BY_SERIAL[serial] : null; } + + @Override + public long idOf(int serial) { + return IDS_BY_SERIAL[serial]; + } // openTelemetryNameOf: id -> OTel-namespace name on spans of a direction, null when // the tag has none there. The caller (a serializer) owns any fall-back-to-Datadog-name diff --git a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorPluginTest.kt b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorPluginTest.kt index 5874dcfb70d..82d1b0de5ec 100644 --- a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorPluginTest.kt +++ b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorPluginTest.kt @@ -247,6 +247,7 @@ class TagRegistryGeneratorPluginTest { public final class KnownTagCodec { public interface Resolver { String nameOf(long id); + long idOf(int serial); String openTelemetryNameOf(long id, int direction); long keyOf(String name); long directionalKeyOf(String name, int direction); diff --git a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-0.3/src/test/groovy/TypeConverterTest.groovy b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-0.3/src/test/groovy/TypeConverterTest.groovy index 60c0f43d9be..9dcf709d75f 100644 --- a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-0.3/src/test/groovy/TypeConverterTest.groovy +++ b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-0.3/src/test/groovy/TypeConverterTest.groovy @@ -53,7 +53,6 @@ class TypeConverterTest extends InstrumentationSpecification { [:], false, "fakeType", - 0, trace, null, null, diff --git a/dd-java-agent/instrumentation/opentracing/opentracing-0.31/src/test/groovy/TypeConverterTest.groovy b/dd-java-agent/instrumentation/opentracing/opentracing-0.31/src/test/groovy/TypeConverterTest.groovy index 3a3b5c6efca..db29f8de153 100644 --- a/dd-java-agent/instrumentation/opentracing/opentracing-0.31/src/test/groovy/TypeConverterTest.groovy +++ b/dd-java-agent/instrumentation/opentracing/opentracing-0.31/src/test/groovy/TypeConverterTest.groovy @@ -65,7 +65,6 @@ class TypeConverterTest extends InstrumentationSpecification { [:], false, "fakeType", - 0, trace, null, null, diff --git a/dd-java-agent/instrumentation/opentracing/opentracing-0.32/src/test/groovy/TypeConverterTest.groovy b/dd-java-agent/instrumentation/opentracing/opentracing-0.32/src/test/groovy/TypeConverterTest.groovy index 9ecfd5221ef..1506f3eb983 100644 --- a/dd-java-agent/instrumentation/opentracing/opentracing-0.32/src/test/groovy/TypeConverterTest.groovy +++ b/dd-java-agent/instrumentation/opentracing/opentracing-0.32/src/test/groovy/TypeConverterTest.groovy @@ -65,7 +65,6 @@ class TypeConverterTest extends InstrumentationSpecification { [:], false, "fakeType", - 0, trace, null, null, diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java index d31137c3aef..2fa5365f935 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java @@ -190,5 +190,11 @@ public final class TracerConfig { public static final String TRACE_ORG_GUARD_STRICT = "trace.org.guard.strict"; public static final String TRACE_ORG_GUARD_TRUSTED_OPMS = "trace.org.guard.trusted.opms"; + /** + * Stores known tags that have a dense-store slot in TagMap's dense store (no per-tag entry) + * instead of its hash buckets. Experimental, off by default. + */ + public static final String TRACE_DENSE_TAGS_ENABLED = "trace.experimental.dense.tags.enabled"; + private TracerConfig() {} } diff --git a/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/TraceMapperBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/TraceMapperBenchmark.java index 54192804b05..f7a6fec6e2c 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/TraceMapperBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/common/writer/ddagent/TraceMapperBenchmark.java @@ -116,7 +116,6 @@ public void init(Blackhole blackhole) { Collections.emptyMap(), false, UTF8BytesString.create("type"), - 0, traceCollector, null, null, diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/GetTagBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/GetTagBenchmark.java new file mode 100644 index 00000000000..57ce57b404f --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/GetTagBenchmark.java @@ -0,0 +1,116 @@ +package datadog.trace.core; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; + +import datadog.trace.api.KnownTags; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Measures reading tags back from a live span, by how the tag is named: a known tag's name, a + * custom name, an OpenTelemetry name ({@code server.address}, which the core treats as a custom + * name), and misses. {@code getTag} takes the span's tag lock; {@code unsafeGetTag} reads without + * it, as the tracer's internal readers do. + */ +@State(Scope.Thread) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(value = 3) +public class GetTagBenchmark { + static final CoreTracer TRACER = CoreTracer.builder().build(); + + private DDSpan span; + + /** + * The known tags the span holds, read in turn by the {@code *_cycle} arms so a read's cost is + * averaged over the span's tags rather than decided by whether one particular tag collides in its + * bucket. A power-of-two length lets the index wrap with a mask. + */ + private static final String[] KNOWN_HELD = { + "span.kind", + KnownTags.HTTP_ROUTE_NAME, + KnownTags.PEER_HOSTNAME_NAME, + KnownTags.HTTP_STATUS_CODE_NAME + }; + + private int next; + + @Setup + public void setup() { + span = (DDSpan) TRACER.startSpan("benchmark", "get.tag"); + span.setTag("span.kind", "client"); + span.setTag(KnownTags.HTTP_ROUTE_NAME, "/users/{id}"); + span.setTag(KnownTags.PEER_HOSTNAME_NAME, "db.example"); + span.setTag(KnownTags.HTTP_STATUS_CODE_NAME, 200); + span.setTag("my.custom.tag", "custom"); + span.setTag("my.other.tag", 42); + } + + @TearDown + public void tearDown() { + span.finish(); + } + + @Benchmark + public Object knownTag_getTag() { + return span.getTag(KnownTags.HTTP_ROUTE_NAME); + } + + @Benchmark + public Object customTag_getTag() { + return span.getTag("my.custom.tag"); + } + + @Benchmark + public Object aliasName_getTag() { + return span.getTag("server.address"); + } + + @Benchmark + public Object missingTag_getTag() { + return span.getTag("not.there"); + } + + /** A known tag the span doesn't hold: a clear slot bit can answer without a probe. */ + @Benchmark + public Object missingKnownTag_getTag() { + return span.getTag(KnownTags.HTTP_METHOD_NAME); + } + + @Benchmark + public Object missingKnownTag_unsafeGetTag() { + return span.unsafeGetTag(KnownTags.HTTP_METHOD_NAME); + } + + @Benchmark + public Object knownTag_unsafeGetTag() { + return span.unsafeGetTag(KnownTags.HTTP_ROUTE_NAME); + } + + /** Every known tag the span holds, in turn; see {@link #KNOWN_HELD}. */ + @Benchmark + public Object knownTags_cycle_unsafeGetTag() { + return span.unsafeGetTag(KNOWN_HELD[next++ & (KNOWN_HELD.length - 1)]); + } + + @Benchmark + public Object knownTags_cycle_getTag() { + return span.getTag(KNOWN_HELD[next++ & (KNOWN_HELD.length - 1)]); + } + + @Benchmark + public Object customTag_unsafeGetTag() { + return span.unsafeGetTag("my.custom.tag"); + } +} diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/PendingTraceWrite.java b/dd-trace-core/src/jmh/java/datadog/trace/core/PendingTraceWrite.java index b0a90c7bffb..dda438dfc16 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/PendingTraceWrite.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/PendingTraceWrite.java @@ -55,7 +55,6 @@ public void init(TraceCounters counters, Blackhole blackhole) { Collections.emptyMap(), false, "type", - 0, traceCollector, null, null, @@ -80,7 +79,6 @@ public void init(TraceCounters counters, Blackhole blackhole) { Collections.emptyMap(), false, "type", - 0, traceCollector, null, null, diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/TracerMapperMap.java b/dd-trace-core/src/jmh/java/datadog/trace/core/TracerMapperMap.java index 4075274cca2..0e9f4c57ac2 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/TracerMapperMap.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TracerMapperMap.java @@ -135,7 +135,6 @@ private DDSpan createSpanWithOrigin(int iter, final String origin) { Collections.emptyMap(), false, "type", - 0, traceCollector, null, null, diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/propagation/InjectorBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/propagation/InjectorBenchmark.java index 1c070167377..b06ce7b3558 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/propagation/InjectorBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/propagation/InjectorBenchmark.java @@ -122,7 +122,6 @@ public void setUp(Blackhole blackhole) { Collections.emptyMap(), false, "type", - 0, tracer.createTraceCollector(traceId), null, null, diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index 8f52b49052c..74b7188402d 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -207,10 +207,43 @@ private static final class LocalRootSpanTags { final TagMap tags; final boolean needsIntercept; + /** The last {@link #traceLevelParent}, with the merged tracer tags it was built over. */ + private volatile TraceLevelTags traceLevel; + LocalRootSpanTags(final TagMap tags, final boolean needsIntercept) { this.tags = tags; this.needsIntercept = needsIntercept; } + + /** + * All of a root span's trace-level tags in one frozen map -- these tags over {@code + * mergedTracerTags} -- for the root span to read through instead of copying them in. The tag + * registry colors trace-level tags separately, so they reuse span-level slot bits; keeping them + * out of the span's own store keeps its slot bits exact. Built once per config snapshot; a racy + * rebuild is harmless, since it builds the same map. + */ + TagMap traceLevelParent(final TagMap mergedTracerTags) { + TraceLevelTags cached = this.traceLevel; + if (cached != null && cached.mergedTracerTags == mergedTracerTags) { + return cached.parent; + } + TagMap parent = TagMap.create(); + parent.putAll(mergedTracerTags); + parent.putAll(this.tags); + parent.freeze(); + this.traceLevel = new TraceLevelTags(mergedTracerTags, parent); + return parent; + } + } + + private static final class TraceLevelTags { + final TagMap mergedTracerTags; + final TagMap parent; + + TraceLevelTags(final TagMap mergedTracerTags, final TagMap parent) { + this.mergedTracerTags = mergedTracerTags; + this.parent = parent; + } } private volatile LocalRootSpanTags localRootSpanTags; @@ -2129,6 +2162,7 @@ protected static final DDSpanContext buildSpanContext( final CharSequence origin; final TagMap coreTags; final boolean coreTagsNeedsIntercept; + final LocalRootSpanTags localRootSpanTags; final TagMap rootSpanTags; final boolean rootSpanTagsNeedsIntercept; final DDSpanContext context; @@ -2158,6 +2192,7 @@ protected static final DDSpanContext buildSpanContext( origin = null; coreTags = null; coreTagsNeedsIntercept = false; + localRootSpanTags = null; rootSpanTags = null; rootSpanTagsNeedsIntercept = false; parentServiceName = ddsc.getServiceName(); @@ -2232,7 +2267,7 @@ protected static final DDSpanContext buildSpanContext( ciVisibilityContextData = null; } - LocalRootSpanTags localRootSpanTags = tracer.localRootSpanTags; + localRootSpanTags = tracer.localRootSpanTags; rootSpanTags = localRootSpanTags.tags; rootSpanTagsNeedsIntercept = localRootSpanTags.needsIntercept; @@ -2311,12 +2346,22 @@ protected static final DDSpanContext buildSpanContext( final TagMap mergedTracerTags = traceConfig.mergedTracerTags; boolean mergedTracerTagsNeedsIntercept = traceConfig.mergedTracerTagsNeedsIntercept; - final int tagsSize = - mergedTracerTags.size() - + (null == tagLedger ? 0 : tagLedger.estimateSize()) - + (null == coreTags ? 0 : coreTags.size()) - + (null == rootSpanTags ? 0 : rootSpanTags.size()) - + (null == contextualTags ? 0 : contextualTags.size()); + // With builder tags taking precedence, a root span reads its trace-level tags (root-span tags + // over the merged tracer tags) through one shared frozen parent instead of copying the + // root-span tags in. They become the precedence floor: the flag's order already puts them + // below builder and contextual tags, and now inbound header tags and prototype defaults also + // win over them. Tags needing interception are still copied in. + final boolean rootSpanTagsReadThrough = + localRootSpanTags != null + && tracer.builderTagsPrecedence + && !rootSpanTagsNeedsIntercept + && !mergedTracerTagsNeedsIntercept; + final TagMap tagParent; + if (rootSpanTagsReadThrough) { + tagParent = localRootSpanTags.traceLevelParent(mergedTracerTags); + } else { + tagParent = mergedTracerTagsNeedsIntercept ? null : mergedTracerTags; + } if (builderRequestContextDataAppSec != null) { requestContextDataAppSec = builderRequestContextDataAppSec; @@ -2345,7 +2390,6 @@ protected static final DDSpanContext buildSpanContext( w3cBaggage, errorFlag, spanType, - tagsSize, parentTraceCollector, requestContextDataAppSec, requestContextDataIast, @@ -2356,7 +2400,7 @@ protected static final DDSpanContext buildSpanContext( tracer.profilingContextIntegration, tracer.injectBaggageAsTags, tracer.injectLinksAsTags, - mergedTracerTagsNeedsIntercept ? null : mergedTracerTags); + tagParent); // By setting the tags on the context we apply decorators to any tags that have been set via // the builder. The `mergedTracerTags` are always applied first (the precedence floor: @@ -2381,7 +2425,9 @@ protected static final DDSpanContext buildSpanContext( } if (tracer.builderTagsPrecedence) { context.setAllTags(coreTags, coreTagsNeedsIntercept); - context.setAllTags(rootSpanTags, rootSpanTagsNeedsIntercept); + if (!rootSpanTagsReadThrough) { + context.setAllTags(rootSpanTags, rootSpanTagsNeedsIntercept); + } context.setAllTags(contextualTags); context.setAllTags(tagLedger); } else { diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 9281c377afb..95d8de1eaee 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -15,6 +15,8 @@ import datadog.trace.api.DDTags; import datadog.trace.api.DDTraceId; import datadog.trace.api.Functions; +import datadog.trace.api.HashStore; +import datadog.trace.api.IdStore; import datadog.trace.api.KnownTagCodec; import datadog.trace.api.KnownTags; import datadog.trace.api.ProcessTags; @@ -56,6 +58,7 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function; import javax.annotation.Nonnull; +import javax.annotation.concurrent.GuardedBy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -138,15 +141,48 @@ public class DDSpanContext private CharSequence serviceNameSource; /** - * Tags are associated to the current span, they will not propagate to the children span. + * The span's known tags, by id. Also the lock for all of the span's tag state. Tags belong to + * this span; they don't propagate to its children. * - *

The underlying assumption for using a normal Map with synchronized access instead of a - * ConcurrentHashMap is that even though the tags can be accessed and modified from multiple - * threads, they will rarely, if ever, be read and modified concurrently by multiple threads but - * rather read and accessed in a serial fashion on thread after thread. The synchronization can - * then be wrapped around bulk operations to minimize the costly atomic operations. + *

Storage. The span owns its tag stores directly. This store holds the known tags and + * is created with the span. {@link #customTags} holds the custom tags by name and is created on + * the first custom write. Until then it is null, or the read-through parent's custom store, + * borrowed read-only while {@link #customTagsBorrowed}. Every read and write is routed by name or + * id straight to one of the stores. Internal bulk readers (post-processors, serializers, {@link + * #getTags()}) go through the cached {@link #tagView()} instead. + * + *

Locking. One lock, this store itself, guards both stores, {@link #customTagsBorrowed} + * and {@link #tagView}. It is this store because it always exists, while {@link #customTags} may + * be null or borrowed. + * + *

    + *
  • The stores are unsynchronized; they rely on this lock. + *
  • Read-through parents are frozen, so reading through them needs no lock. + *
  • The lock is reentrant, and that matters: code that runs while it is held may call back + * into the span's setters. The tag interceptor does so from {@code setAllTags}, and + * post-processors do so through the view from {@code processTagsAndBaggage}. + *
  • {@link #unsafeGetTag} and {@code unsafeSetTag} skip the lock. Callers either hold it + * already or guarantee that nothing mutates the span concurrently. + *
+ * + *

A plain lock rather than concurrent structures: tags are rarely read and written by several + * threads at once. They are touched by one thread after another, so a lock around each operation, + * or around a bulk operation, costs less than atomic operations on every access. */ - private final TagMap unsafeTags; + @GuardedBy("knownTags") + private final IdStore knownTags; + + /** Custom tags by name: null, borrowed from the read-through parent, or the span's own. */ + @GuardedBy("knownTags") + private HashStore customTags; + + /** True while {@link #customTags} is the read-through parent's store, borrowed read-only. */ + @GuardedBy("knownTags") + private boolean customTagsBorrowed; + + /** This span's view of its tag stores for its internal readers; see {@link #tagView()}. */ + @GuardedBy("knownTags") + private TagMap tagView; /** The service name is required, otherwise the span are dropped by the agent */ private volatile String serviceName; @@ -216,7 +252,6 @@ public DDSpanContext( final Map baggageItems, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -238,7 +273,6 @@ public DDSpanContext( null, errorFlag, spanType, - tagsSize, traceCollector, requestContextDataAppSec, requestContextDataIast, @@ -265,7 +299,6 @@ public DDSpanContext( final Map baggageItems, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -289,7 +322,6 @@ public DDSpanContext( null, errorFlag, spanType, - tagsSize, traceCollector, requestContextDataAppSec, requestContextDataIast, @@ -319,7 +351,6 @@ public DDSpanContext( final Baggage w3cBaggage, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -345,7 +376,6 @@ public DDSpanContext( w3cBaggage, errorFlag, spanType, - tagsSize, traceCollector, requestContextDataAppSec, requestContextDataIast, @@ -374,7 +404,6 @@ public DDSpanContext( final Baggage w3cBaggage, final boolean errorFlag, final CharSequence spanType, - final int tagsSize, final TraceCollector traceCollector, final Object requestContextDataAppSec, final Object requestContextDataIast, @@ -411,13 +440,11 @@ public DDSpanContext( assert pathwayContext != null; this.pathwayContext = pathwayContext; - // The +1 is the magic number from the tags below that we set at the end, - // and "* 4 / 3" is to make sure that we don't resize immediately - final int capacity = Math.max((tagsSize <= 0 ? 3 : (tagsSize + 1)) * 4 / 3, 8); - this.unsafeTags = - readThroughParent != null - ? TagMap.createFromParent(readThroughParent) - : TagMap.create(capacity); + // Reads through to the frozen tracer-level tags, if any. + TagMap parentTags = TagMap.readThroughParent(readThroughParent); + this.knownTags = IdStore.create(parentTags); + this.customTags = HashStore.inheritedFrom(parentTags); + this.customTagsBorrowed = this.customTags != null; // must set this before setting the service and resource names below this.profilingContextIntegration = profilingContextIntegration; @@ -666,16 +693,15 @@ private void seedAbsentTags(final TagMap map) { if (map == null) { return; } - synchronized (unsafeTags) { + synchronized (knownTags) { map.forEach( this, (ctx, tagEntry) -> { - final String tag = tagEntry.tag(); - if (ctx.unsafeTags.containsKey(tag)) { + if (ctx.isPresent(tagEntry)) { return; } if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.unsafeTags.set(tagEntry); + ctx.routePut(tagEntry); } }); } @@ -800,7 +826,7 @@ public int getSamplingPriority() { } public void setSpanSamplingPriority(double rate, int limit) { - synchronized (unsafeTags) { + synchronized (knownTags) { unsafeSetTag(SPAN_SAMPLING_MECHANISM_TAG, SamplingMechanism.SPAN_SAMPLING_RATE); unsafeSetTag(SPAN_SAMPLING_RULE_RATE_TAG, rate); if (limit != Integer.MAX_VALUE) { @@ -929,6 +955,139 @@ public void setSpanKindOrdinal(String kind) { spanKindOrdinal = spanKindOrdinalOf(kind); } + /** + * This span's cached view of its tag stores, for its internal bulk readers (post-processors, the + * serializer, the snapshot copy) -- never its own set/get paths. The view keeps the stores it was + * created over: {@link #customTagsForWrite} drops it when the span creates its own custom store. + * Bulk readers run late in a span's life, after its custom writes, so that is rare. Call under + * the {@code knownTags} lock. + */ + private TagMap tagView() { + TagMap view = this.tagView; + if (view == null) { + // a view handed to the post-processor chain escapes, so allocate it once per span + this.tagView = view = TagMap.viewOf(knownTags, customTags, customTagsBorrowed); + } + return view; + } + + /** + * Adopts the custom store a reader may have created through the view (by writing a custom tag), + * so the span's own paths see it. Call under the {@code knownTags} lock, after using the view. + */ + private void adoptViewStores(TagMap view) { + // Skipped when the span dropped this view during its use: a callback into a span setter + // created the span's own custom store, which must not be replaced by the view's older one. + if (view == this.tagView) { + this.customTags = view.customStore(); + this.customTagsBorrowed = view.isCustomStoreBorrowed(); + } + } + + /** + * The custom store to write to: created on the first custom write, ending any borrowing. A cached + * view still holds the old store, so it is dropped. + */ + private HashStore customTagsForWrite() { + HashStore custom = this.customTags; + if (custom == null || this.customTagsBorrowed) { + custom = HashStore.create(custom); // reads through to the store borrowed till now + this.customTags = custom; + this.customTagsBorrowed = false; + this.tagView = null; + } + return custom; + } + + /** + * Removes the custom tag {@code name}: hiding an inherited one needs a store of the span's own. + */ + private void removeCustomTag(String name) { + HashStore custom = this.customTags; + if (custom == null) { + return; + } + if (this.customTagsBorrowed) { + if (custom.get(name) == null) { + return; // nothing to hide: keep borrowing + } + custom = customTagsForWrite(); + } + custom.remove(name); + } + + /** Removes {@code tag}, known or custom. Call under the {@code knownTags} lock. */ + private void routeRemoval(String tag) { + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + knownTags.remove(key); + } else { + removeCustomTag(tag); + } + } + + /** + * Copies {@code map}'s tags into the stores, sharing its Entry objects where a store keeps them. + */ + private void routePutAll(TagMap map) { + // straight into the stores: a view created this early would likely be dropped by a later + // custom write + map.forEach(this, (ctx, entryReader) -> ctx.routePut(entryReader)); + } + + /* + * The route* operations send a tag to the store that holds it: the known store by id, else the + * custom store by name. Call them while holding the knownTags lock. + */ + + private void routeSet(long key, String tag, Object value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, CharSequence value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, int value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, long value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, float value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + + private void routeSet(long key, String tag, double value) { + if (key != 0) { + knownTags.set(key, value); + } else { + customTagsForWrite().set(tag, value); + } + } + static byte spanKindOrdinalOf(String kind) { if (kind == null) { return SPAN_KIND_UNSET; @@ -960,8 +1119,9 @@ public String getSpanKindString() { return SPAN_KIND_VALUES[ordinal]; } // UNSET or CUSTOM -- fall through to tag map - synchronized (unsafeTags) { - return unsafeTags.getString(Tags.SPAN_KIND); + synchronized (knownTags) { + TagMap.EntryReader kind = knownTags.getEntryReader(KnownTags.SPAN_KIND_ID); + return kind == null ? null : kind.stringValue(); } } @@ -972,32 +1132,32 @@ public void setOrigin(final CharSequence origin) { } public void setMetric(final CharSequence key, final Number value) { - synchronized (unsafeTags) { + synchronized (knownTags) { unsafeSetTag(key.toString(), value); } } public void setMetric(final CharSequence key, final int value) { - synchronized (unsafeTags) { - unsafeTags.set(key.toString(), value); + synchronized (knownTags) { + routeSet(key.toString(), value); } } public void setMetric(final CharSequence key, final long value) { - synchronized (unsafeTags) { - unsafeTags.set(key.toString(), value); + synchronized (knownTags) { + routeSet(key.toString(), value); } } public void setMetric(final CharSequence key, final float value) { - synchronized (unsafeTags) { - unsafeTags.set(key.toString(), value); + synchronized (knownTags) { + routeSet(key.toString(), value); } } public void setMetric(final CharSequence key, final double value) { - synchronized (unsafeTags) { - unsafeTags.set(key.toString(), value); + synchronized (knownTags) { + routeSet(key.toString(), value); } } @@ -1006,24 +1166,64 @@ public void setMetric(final TagMap.EntryReader entry) { return; } - synchronized (unsafeTags) { - unsafeTags.set(entry); + synchronized (knownTags) { + routePut(entry); + } + } + + /* + * Route a tag that interception let through, by name: resolved once to the store that holds it. + */ + + private void routeSet(String tag, Object value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, CharSequence value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, int value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, long value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, float value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + private void routeSet(String tag, double value) { + routeSet(KnownTagCodec.keyOf(tag), tag, value); + } + + /** Stores {@code entry}, sharing its Entry where the store keeps Entry objects. */ + private void routePut(TagMap.EntryReader entryReader) { + if (entryReader.tagId() != 0) { + knownTags.put(entryReader); // no Entry built for a dense store, or for a flyweight from one + } else { + TagMap.Entry entry = entryReader.entry(); + if (entry != null) { // a reader with no entry is a no-op, as for TagMap.set + customTagsForWrite().put(entry); + } } } public void removeTag(String tag) { if (tagEquals(tag, Tags.SPAN_KIND)) { - // Clear the cached ordinal; unsafeTags still needs to be updated below. + // Clear the cached ordinal; the tag store still needs to be updated below. spanKindOrdinal = SPAN_KIND_UNSET; } - synchronized (unsafeTags) { - unsafeTags.remove(tag); + synchronized (knownTags) { + routeRemoval(tag); } } /** - * Removes a known tag by id rather than by its name. An id the id-keyed setters ignore is ignored - * here too. + * Removes a known tag by id rather than by its name. An id that names no known tag is ignored, + * like the id-keyed setters do. * * @param tagId a {@code KnownTags.*_ID} constant */ @@ -1034,9 +1234,19 @@ public void removeTag(long tagId) { if (tagId == KnownTags.SPAN_KIND_ID) { spanKindOrdinal = SPAN_KIND_UNSET; } - synchronized (unsafeTags) { - unsafeTags.getAndRemove(tagId); + synchronized (knownTags) { + knownTags.remove(tagId); + } + } + + /** Whether {@code entry}'s tag is already on this span. */ + private boolean isPresent(TagMap.EntryReader entry) { + long key = entry.tagId(); // the reader already carries its id: no name lookup + if (key != 0) { + return knownTags.getValue(key) != null; } + HashStore custom = customTags; + return custom != null && custom.get(entry.tag()) != null; } /** @@ -1061,8 +1271,8 @@ public void setTag(final String tag, final Object value) { } else if (null == value) { removeTag(tag); } else if (!tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1077,8 +1287,8 @@ public void setTag(final String tag, final String value) { } else if (null == value) { removeTag(tag); } else if (!tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1094,10 +1304,11 @@ public void setTag(final long tagId, final Object value) { } if (null == value) { removeTag(tagId); - } else if (!tagInterceptor.needsIntercept(tagId) - || !tagInterceptor.interceptTag(this, tagId, value)) { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + return; + } + if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1108,10 +1319,11 @@ public void setTag(final long tagId, final CharSequence value) { } if (null == value) { removeTag(tagId); - } else if (!tagInterceptor.needsIntercept(tagId) - || !tagInterceptor.interceptTag(this, tagId, value)) { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + return; + } + if (!tagInterceptor.needsIntercept(tagId) || !tagInterceptor.interceptTag(this, tagId, value)) { + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1123,8 +1335,8 @@ public void setTag(final long tagId, final boolean value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1136,8 +1348,8 @@ public void setTag(final long tagId, final int value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1149,8 +1361,8 @@ public void setTag(final long tagId, final long value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1162,8 +1374,8 @@ public void setTag(final long tagId, final float value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1175,8 +1387,8 @@ public void setTag(final long tagId, final double value) { if (tagInterceptor.needsIntercept(tagId)) { this.setBox(tagId, value); } else { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, value); + synchronized (knownTags) { + knownTags.set(tagId, value); } } } @@ -1188,8 +1400,8 @@ public void setTag(TagMap.EntryReader entry) { // the interceptor prechecks the entry, to avoid boxing if (!tagInterceptor.interceptTag(this, entry)) { - synchronized (unsafeTags) { - unsafeTags.set(entry); + synchronized (knownTags) { + routePut(entry); } } } @@ -1208,8 +1420,8 @@ public void setTag(TagMap.EntryReader entry) { */ private void setBox(long tagId, Object box) { if (!tagInterceptor.interceptTag(this, tagId, box)) { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, box); + synchronized (knownTags) { + knownTags.set(tagId, box); } } } @@ -1223,8 +1435,8 @@ public void setTag(final String tag, final boolean value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1238,8 +1450,8 @@ public void setTag(final String tag, final int value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1253,8 +1465,8 @@ public void setTag(final String tag, final long value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1268,8 +1480,8 @@ public void setTag(final String tag, final float value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1283,8 +1495,8 @@ public void setTag(final String tag, final double value) { setTag(tagId, value); } else if (!tagInterceptor.needsInterceptCustomTag(tag) || !tagInterceptor.interceptCustomTag(this, tag, value)) { - synchronized (unsafeTags) { - unsafeTags.set(tag, value); + synchronized (knownTags) { + customTagsForWrite().set(tag, value); // already known to be custom } } } @@ -1298,7 +1510,7 @@ void setAllTags(final TagMap map, boolean needsIntercept) { return; } - synchronized (unsafeTags) { + synchronized (knownTags) { if (needsIntercept) { // forEach out-performs the iterator of TagMap // Taking advantage of ability to pass through other context arguments @@ -1307,11 +1519,11 @@ void setAllTags(final TagMap map, boolean needsIntercept) { this, (ctx, tagEntry) -> { if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.unsafeTags.set(tagEntry); + ctx.routePut(tagEntry); } }); } else { - unsafeTags.putAll(map); + routePutAll(map); } } } @@ -1321,20 +1533,20 @@ void setAllTags(final TagMap.Ledger ledger) { return; } - synchronized (unsafeTags) { + synchronized (knownTags) { for (final TagMap.EntryChange entryChange : ledger) { String tag = entryChange.tag(); if (entryChange.isRemoval()) { if (tagEquals(tag, Tags.SPAN_KIND)) { - // mirror removeTag(String): keep the cached ordinal in sync with unsafeTags + // mirror removeTag(String): keep the cached ordinal in sync with the tags spanKindOrdinal = SPAN_KIND_UNSET; } - unsafeTags.remove(tag); + routeRemoval(tag); } else { TagMap.Entry entry = (TagMap.Entry) entryChange; if (!tagInterceptor.interceptTag(this, entry)) { - unsafeTags.set(entry); + routePut(entry); } } } @@ -1347,9 +1559,14 @@ void setAllTags(final Map map) { } else if (map instanceof TagMap) { setAllTags((TagMap) map); } else if (!map.isEmpty()) { - synchronized (unsafeTags) { + synchronized (knownTags) { for (final Map.Entry tag : map.entrySet()) { - if (!tagInterceptor.interceptTag(this, tag.getKey(), tag.getValue())) { + long tagId = KnownTagCodec.keyOf(tag.getKey()); + boolean intercepted = + tagId != 0 + ? tagInterceptor.interceptTag(this, tagId, tag.getValue()) + : tagInterceptor.interceptCustomTag(this, tag.getKey(), tag.getValue()); + if (!intercepted) { unsafeSetTag(tag.getKey(), tag.getValue()); } } @@ -1358,19 +1575,19 @@ void setAllTags(final Map map) { } void unsafeSetTag(final String tag, final Object value) { - unsafeTags.set(tag, value); + routeSet(tag, value); } void unsafeSetTag(final String tag, final CharSequence value) { - unsafeTags.set(tag, value); + routeSet(tag, value); } void unsafeSetTag(final String tag, final byte value) { - unsafeTags.set(tag, value); + routeSet(tag, value); } void unsafeSetTag(final String tag, final double value) { - unsafeTags.set(tag, value); + routeSet(tag, value); } Object getTag(final String key) { @@ -1387,7 +1604,7 @@ Object getTag(final String key) { return getSpanKindString(); default: Object value; - synchronized (unsafeTags) { + synchronized (knownTags) { value = unsafeGetTag(key); } // maintain previously observable type of http url :| @@ -1403,13 +1620,19 @@ Object getTag(final String key) { * @return the value associated with the tag */ public Object unsafeGetTag(final String tag) { - return unsafeTags.getObject(tag); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return knownTags.getValue(key); + } + HashStore custom = customTags; + TagMap.Entry entry = custom == null ? null : custom.get(tag); + return entry == null ? null : entry.objectValue(); } @Deprecated public TagMap getTags() { - synchronized (unsafeTags) { - TagMap tags = unsafeTags.copy(); + synchronized (knownTags) { + TagMap tags = tagView().copy(); tags.put(DDTags.THREAD_ID, threadId); // maintain previously observable type of the thread name :| @@ -1461,8 +1684,10 @@ public void setMetaStruct(final String field, final T value) { } void earlyProcessTags(AppendableSpanLinks links) { - synchronized (unsafeTags) { - TagsPostProcessorFactory.eagerProcessor().processTags(unsafeTags, this, links); + synchronized (knownTags) { + TagMap view = tagView(); + TagsPostProcessorFactory.eagerProcessor().processTags(view, this, links); + adoptViewStores(view); } } @@ -1491,15 +1716,18 @@ void processTagsAndBaggage( // This is a compromise to avoid... // - creating an extra wrapper object that would create significant allocation // - implementing an interface to read the spans that require making the read method public - synchronized (unsafeTags) { + synchronized (knownTags) { + // One live view of the tag stores, for the post-processors and the serializer + TagMap tags = tagView(); + // Tags - TagsPostProcessorFactory.lazyProcessor().processTags(unsafeTags, this, restrictedSpan); + TagsPostProcessorFactory.lazyProcessor().processTags(tags, this, restrictedSpan); // Links if (injectLinksAsTags) { String linksTag = DDSpanLink.toTag(restrictedSpan.getLinks()); if (linksTag != null) { - unsafeTags.set(SPAN_LINKS, linksTag); + tags.set(SPAN_LINKS, linksTag); } } @@ -1508,7 +1736,7 @@ void processTagsAndBaggage( if (injectEventsAsTags) { String eventsTag = spanEventsToTag(events); if (eventsTag != null) { - unsafeTags.set(SPAN_EVENTS, eventsTag); + tags.set(SPAN_EVENTS, eventsTag); } } @@ -1533,7 +1761,7 @@ void processTagsAndBaggage( new Metadata( threadId, threadName, - unsafeTags, + tags, baggageItemsWithPropagationTags, getSamplingPriority(), measured, @@ -1546,6 +1774,7 @@ void processTagsAndBaggage( Config.get().isOtlpTracesExportEnabled() ? OTLP_EXPORT_TRUE : OTLP_EXPORT_FALSE, restrictedSpan.getLinks(), events)); + adoptViewStores(tags); } } @@ -1696,7 +1925,7 @@ public String toString() { s.append(" *measured*"); } - synchronized (unsafeTags) { + synchronized (knownTags) { s.append(" tags=").append(new TreeMap<>(getTags())); } return s.toString(); diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java index cf756003e7c..41b53b7ab5d 100644 --- a/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java @@ -217,7 +217,6 @@ DDSpan realSpan() throws Exception { Collections.emptyMap(), false, "", - 0, trace, null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDCoreJavaSpecification.java b/dd-trace-core/src/test/java/datadog/trace/core/DDCoreJavaSpecification.java index d5fead39ea7..b83d9c16bd4 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/DDCoreJavaSpecification.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDCoreJavaSpecification.java @@ -115,7 +115,6 @@ protected DDSpan buildSpan( null, false, spanType, - 0, tracer.createTraceCollector(DDTraceId.ONE), null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextStoresTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextStoresTest.java new file mode 100644 index 00000000000..d098d638517 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextStoresTest.java @@ -0,0 +1,297 @@ +package datadog.trace.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.common.writer.ListWriter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * How a span routes tags to its own known and custom stores: custom tags by name with every value + * type, primitive sets of intercepted tags by id, map writes, and the custom store borrowed from + * the tracer-level tags until the span's first custom write. + */ +class DDSpanContextStoresTest extends DDCoreJavaSpecification { + private CoreTracer tracer; + + @BeforeEach + void setup() { + tracer = + tracerBuilder() + .writer(new ListWriter()) + .defaultSpanTags(Collections.singletonMap("global.custom", "from-tracer")) + .build(); + } + + @AfterEach + void tearDown() { + tracer.close(); + } + + private DDSpan start() { + return (DDSpan) tracer.startSpan("test-instr", "stores.op"); + } + + @Test + void aReaderWithNoEntryIsANoOp() { + DDSpan span = start(); + try { + span.setTag("http.route", "/users"); + span.setTag("custom.kept", "kept"); + span.spanContext().setTag(new NoEntryReader("http.route", KnownTags.HTTP_ROUTE_ID)); + span.spanContext().setTag(new NoEntryReader("custom.kept", 0L)); + span.spanContext().setMetric(new NoEntryReader("custom.metric", 0L)); + + assertEquals("/users", span.getTag("http.route")); + assertEquals("kept", span.getTag("custom.kept")); + assertNull(span.getTag("custom.metric")); + } finally { + span.finish(); + } + } + + @Test + void customTagsKeepEveryValueType() { + DDSpan span = start(); + try { + span.setTag("custom.chars", (CharSequence) "chars"); + span.setTag("custom.bool", true); + span.setTag("custom.int", 7); + span.setTag("custom.long", 8L); + span.setTag("custom.float", 1.5f); + span.setTag("custom.double", 2.5d); + + assertEquals("chars", span.getTag("custom.chars")); + assertEquals(true, span.getTag("custom.bool")); + assertEquals(7, span.getTag("custom.int")); + assertEquals(8L, span.getTag("custom.long")); + assertEquals(1.5f, span.getTag("custom.float")); + assertEquals(2.5d, span.getTag("custom.double")); + } finally { + span.finish(); + } + } + + @Test + void interceptedTagsSetByIdAsPrimitivesAreRouted() { + DDSpan span = start(); + try { + span.spanContext().setTag(KnownTags.HTTP_STATUS_CODE_ID, 404); + assertEquals(404, span.getHttpStatusCode()); + span.spanContext().setTag(KnownTags.HTTP_STATUS_CODE_ID, 503L); + assertEquals(503, span.getHttpStatusCode()); + + span.spanContext().setTag(KnownTags.DD_MEASURED_ID, true); + assertTrue(span.isMeasured()); + + span.spanContext().setTag(KnownTags.DD1_SR_EAUSR_ID, 0.25d); + span.spanContext().setTag(KnownTags.DD1_SR_EAUSR_ID, 0.5f); + assertEquals(0.5f, ((Number) span.getTag("_dd1.sr.eausr")).floatValue()); + } finally { + span.finish(); + } + } + + @Test + void knownTagsSetByIdAsPrimitivesWithoutInterception() { + DDSpan span = start(); + try { + span.spanContext().setTag(KnownTags.PEER_PORT_ID, 5432L); + span.spanContext().setTag(KnownTags.DB_INSTANCE_ID, 1.5f); + span.spanContext().setTag(KnownTags.DB_POOL_NAME_ID, 2.5d); + assertEquals(5432L, span.getTag("peer.port")); + assertEquals(1.5f, span.getTag("db.instance")); + assertEquals(2.5d, span.getTag("db.pool.name")); + + // an intercepted tag the interceptor declines is still stored: span.kind records its + // ordinal and keeps the value + span.spanContext().setTag(KnownTags.SPAN_KIND_ID, 1); + assertEquals("1", String.valueOf(span.getTag("span.kind"))); + } finally { + span.finish(); + } + } + + @Test + void metricsWithKnownNamesGoToTheKnownStore() { + DDSpan span = start(); + try { + span.setMetric("db.user", 200); + span.setMetric("peer.port", 8080L); + span.setMetric("db.instance", 0.5f); + span.setMetric("db.pool.name", 0.75d); + span.setMetric("custom.metric", 3L); + assertEquals(200, span.getTag("db.user")); + assertEquals(8080L, span.getTag("peer.port")); + assertEquals(0.5f, span.getTag("db.instance")); + assertEquals(0.75d, span.getTag("db.pool.name")); + assertEquals(3L, span.getTag("custom.metric")); + } finally { + span.finish(); + } + } + + @Test + void aMapWriteRoutesKnownAndCustomTags() { + DDSpan span = start(); + try { + Map tags = new LinkedHashMap<>(); + tags.put("http.route", "/users"); + tags.put("custom.from.map", "value"); + tags.put("custom.chars.from.map", new StringBuilder("chars")); + span.setAllTags(tags); + span.spanContext().unsafeSetTag("custom.unsafe", (CharSequence) "unsafe"); + + assertEquals("/users", span.getTag("http.route")); + assertEquals("value", span.getTag("custom.from.map")); + assertEquals("chars", String.valueOf(span.getTag("custom.chars.from.map"))); + assertEquals("unsafe", span.getTag("custom.unsafe")); + } finally { + span.finish(); + } + } + + @Test + void removingACustomTagTheSpanDoesNotHaveKeepsBorrowing() { + DDSpan span = start(); + try { + assertEquals("from-tracer", span.getTag("global.custom")); + span.removeTag("never.set"); // nothing to hide: the borrowed store stays as it is + assertEquals("from-tracer", span.getTag("global.custom")); + } finally { + span.finish(); + } + } + + @Test + void removingABorrowedCustomTagHidesItFromThisSpanOnly() { + DDSpan span = start(); + DDSpan other = start(); + try { + span.removeTag("global.custom"); // copies the borrowed store, then hides the tag + assertNull(span.getTag("global.custom")); + assertEquals("from-tracer", other.getTag("global.custom"), "the tracer-level tag stays"); + } finally { + span.finish(); + other.finish(); + } + } + + @Test + void aPrototypesCustomTagIsSeededOnlyWhereAbsent() { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("stores.proto") + .initTag("global.custom", "from-prototype") + .initTag("proto.custom", "seeded") + .build(); + DDSpan span = (DDSpan) tracer.startSpan(prototype, null); + try { + // the tracer-level tag is already visible, so the prototype's default doesn't replace it + assertEquals("from-tracer", span.getTag("global.custom")); + assertEquals("seeded", span.getTag("proto.custom")); + } finally { + span.finish(); + } + } + + /** An EntryReader with no entry and no value, as a caller outside the tracer may pass. */ + static final class NoEntryReader implements datadog.trace.api.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 datadog.trace.api.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 datadog.trace.api.TagMap.Entry entry() { + return null; + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTagViewTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTagViewTest.java new file mode 100644 index 00000000000..561dfc33bcc --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanContextTagViewTest.java @@ -0,0 +1,60 @@ +package datadog.trace.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.trace.api.TagMap; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.util.Collections; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * The span's cached view of its tag stores keeps the stores it was created over, so a custom store + * the span creates afterwards must replace the view rather than be missed by it. + */ +class DDSpanContextTagViewTest extends DDCoreJavaSpecification { + private CoreTracer tracer; + + @AfterEach + void cleanup() { + if (tracer != null) { + tracer.close(); + } + } + + @Test + void aCustomTagWrittenAfterTheViewIsCreatedIsSeenThroughANewView() { + tracer = tracerBuilder().build(); + AgentSpan span = tracer.buildSpan("test", "op").start(); + try { + DDSpanContext context = (DDSpanContext) span.spanContext(); + assertEquals(null, context.getTags().get("custom.first")); // creates the view + + span.setTag("custom.first", "a"); // the span's first custom store: the view is dropped + span.setTag("custom.second", "b"); + + TagMap tags = context.getTags(); + assertEquals("a", tags.get("custom.first")); + assertEquals("b", tags.get("custom.second")); + } finally { + span.finish(); + } + } + + @Test + void aCustomTagCopiedInFromAMapIsStoredWithoutAView() { + tracer = tracerBuilder().build(); + AgentSpan span = tracer.buildSpan("test", "op").start(); + try { + DDSpanContext context = (DDSpanContext) span.spanContext(); + context.setAllTags(TagMap.fromMap(Collections.singletonMap("custom.copied", "c"))); + span.setTag("custom.later", "d"); + + TagMap tags = context.getTags(); + assertEquals("c", tags.get("custom.copied")); + assertEquals("d", tags.get("custom.later")); + } finally { + span.finish(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSerializationTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSerializationTest.java index b8ca25a6cc5..1ab43205181 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSerializationTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanSerializationTest.java @@ -217,7 +217,6 @@ void serializeTraceWithBaggageAndTagsCorrectlyV04( baggage, false, null, - tags.size(), tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -294,7 +293,6 @@ void serializeTraceWithBaggageAndTagsCorrectlyV05( baggage, false, null, - tags.size(), tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -353,7 +351,7 @@ void serializeTraceWithConfiguredBaggageAndTagsCorrectlyV1(String scenario, bool w3cBaggageItems.put("ignored", "ignored-value"); DDSpanContext context = - createSpanContext(tracer, baggage, Baggage.create(w3cBaggageItems), injectBaggage, true, 1); + createSpanContext(tracer, baggage, Baggage.create(w3cBaggageItems), injectBaggage, true); context.setTag("span-tag", "span-value"); DDSpan span = DDSpan.create("test", 0, context, null); @@ -377,7 +375,7 @@ void serializeTraceWithConfiguredBaggageAndTagsCorrectlyV1(String scenario, bool @Test void serializeTraceWithSpanLinksAsStructuredLinksOnlyV1() throws Exception { CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); - DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true, 0); + DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true); DDSpan span = DDSpan.create("test", 0, context, null); Map linkAttributes = new HashMap<>(); @@ -407,7 +405,7 @@ void serializeTraceWithSpanLinksAsStructuredLinksOnlyV1() throws Exception { @Test void serializeTraceWithSpanEventsAsStructuredEventsOnlyV1() throws Exception { CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); - DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true, 1); + DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true); DDSpan span = DDSpan.create("test", 0, context, null); span.addEvent("event.one", typedAttributes(), 1234567890L, NANOSECONDS); span.addEvent("event.two", Collections.emptyMap(), 1234567891L, NANOSECONDS); @@ -439,7 +437,7 @@ void serializeTraceWithSpanEventsAsStructuredEventsOnlyV1() throws Exception { }) void serializeTraceWithSpanEventsAsEventsTag(String protocol) throws Exception { CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); - DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true, 1); + DDSpanContext context = createSpanContext(tracer, Collections.emptyMap(), null, true, true); DDSpan span = DDSpan.create("test", 0, context, null); span.addEvent("event.one", typedAttributes(), 1234567890L, NANOSECONDS); span.addEvent("event \"two\"", Collections.emptyMap(), 1234567891L, NANOSECONDS); @@ -536,7 +534,6 @@ void serializeTraceWithFlatMapTagV04() throws Exception { null, false, null, - 0, tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -603,7 +600,6 @@ void serializeTraceWithFlatMapTagV05() throws Exception { null, false, null, - 0, tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -747,7 +743,6 @@ private DDSpanContext createSpanContext( baggage, false, spanType, - 1, tracer.createTraceCollector(DDTraceId.ONE), null, null, @@ -765,8 +760,7 @@ private DDSpanContext createSpanContext( Map baggage, Baggage w3cBaggage, boolean injectBaggage, - boolean injectLinks, - int tagsSize) { + boolean injectLinks) { return new DDSpanContext( DDTraceId.ONE, 1, @@ -782,7 +776,6 @@ private DDSpanContext createSpanContext( w3cBaggage, false, "fakeType", - tagsSize, tracer.createTraceCollector(DDTraceId.ONE), null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanTest.java b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanTest.java index 1d252859739..46659a5e9ad 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/DDSpanTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/DDSpanTest.java @@ -423,7 +423,6 @@ void inferTopLevelFromParentServiceName( Collections.emptyMap(), false, "fakeType", - 0, tracer.createTraceCollector(DDTraceId.ONE), null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/LongRunningTracesTrackerTest.java b/dd-trace-core/src/test/java/datadog/trace/core/LongRunningTracesTrackerTest.java index 27c8156e27f..aab36487f1e 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/LongRunningTracesTrackerTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/LongRunningTracesTrackerTest.java @@ -228,7 +228,6 @@ private static DDSpan newSpanOf(PendingTrace trace, int samplingPriority, long t Collections.emptyMap(), false, "fakeType", - 0, trace, null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceBufferTest.java b/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceBufferTest.java index e72e5fa3a7c..99bc9481c50 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceBufferTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceBufferTest.java @@ -530,7 +530,6 @@ private static DDSpan newSpanOf(PendingTrace trace, int samplingPriority, long t Collections.emptyMap(), false, "fakeType", - 0, trace, null, null, @@ -556,7 +555,6 @@ private static DDSpan newSpanOf(DDSpan parent) { Collections.emptyMap(), false, "fakeType", - 0, traceCollector, null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceTest.java b/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceTest.java index 7d47a0b17c5..e4b003cb688 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/PendingTraceTest.java @@ -65,7 +65,6 @@ private DDSpan createSimpleSpanWithID(PendingTrace trace, long id) { Collections.emptyMap(), false, "", - 0, trace, null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/RootSpanTagsReadThroughTest.java b/dd-trace-core/src/test/java/datadog/trace/core/RootSpanTagsReadThroughTest.java new file mode 100644 index 00000000000..ad9c16a0b9b --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/RootSpanTagsReadThroughTest.java @@ -0,0 +1,90 @@ +package datadog.trace.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.trace.api.KnownTags; +import datadog.trace.api.config.TracerConfig; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * With builder tags taking precedence, a root span reads its root-span tags through the shared + * trace-level parent instead of holding copies: the same values, none of them stored locally, so + * the span's own store keeps only span-level tags (the slot coloring lets a span-level and a + * trace-level tag share a slot bit). + */ +class RootSpanTagsReadThroughTest extends DDCoreJavaSpecification { + private static final String CUSTOM_ROOT_TAG = "only_root"; + + private CoreTracer tracer; + + @AfterEach + void cleanup() { + if (tracer != null) { + tracer.close(); + } + } + + private CoreTracer tracer(boolean builderTagsPrecedence) { + Map rootTags = new LinkedHashMap<>(); + rootTags.put(KnownTags.LANGUAGE_NAME, "jvm"); + rootTags.put(KnownTags.PROCESS_ID_NAME, 42L); + rootTags.put(CUSTOM_ROOT_TAG, "value"); + Properties properties = new Properties(); + properties.setProperty( + TracerConfig.TRACE_BUILDER_TAGS_PRECEDENCE_ENABLED, String.valueOf(builderTagsPrecedence)); + return tracerBuilder().withProperties(properties).localRootSpanTags(rootTags).build(); + } + + @Test + void aRootSpanReadsItsRootTagsThroughTheTraceLevelParent() throws Exception { + tracer = tracer(false); + int copiedLocally = localKnownTagCount(rootSpanOf(tracer)); + tracer.close(); + + tracer = tracer(true); + DDSpan root = rootSpanOf(tracer); + assertEquals("jvm", root.getTag(KnownTags.LANGUAGE_NAME)); + assertEquals(42L, root.getTag(KnownTags.PROCESS_ID_NAME)); + assertEquals("value", root.getTag(CUSTOM_ROOT_TAG)); + assertEquals(copiedLocally - 2, localKnownTagCount(root)); + } + + @Test + void aChildSpanStillGetsNoRootTags() { + tracer = tracer(true); + AgentSpan root = tracer.buildSpan("test", "root").start(); + AgentSpan child = tracer.buildSpan("test", "child").asChildOf(root.spanContext()).start(); + try { + assertNull(child.getTag(KnownTags.LANGUAGE_NAME)); + assertNull(child.getTag(CUSTOM_ROOT_TAG)); + } finally { + child.finish(); + root.finish(); + } + } + + private static DDSpan rootSpanOf(CoreTracer tracer) { + AgentSpan span = tracer.buildSpan("test", "root").start(); + span.finish(); + return (DDSpan) span; + } + + /** The known tags the span's own id store holds, not counting what it reads through. */ + private static int localKnownTagCount(DDSpan span) throws Exception { + Field knownTags = DDSpanContext.class.getDeclaredField("knownTags"); + knownTags.setAccessible(true); + Object ids = knownTags.get(span.spanContext()); + Method computeSize = + Class.forName("datadog.trace.api.IdStore").getDeclaredMethod("computeSize"); + computeSize.setAccessible(true); + return (Integer) computeSize.invoke(ids); + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/propagation/AbstractHttpInjectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/propagation/AbstractHttpInjectorTest.java index 3098ab217ec..306984b790f 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/propagation/AbstractHttpInjectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/propagation/AbstractHttpInjectorTest.java @@ -51,7 +51,6 @@ protected DDSpanContext mockSpanContext( baggage, false, "fakeType", - 0, this.tracer.createTraceCollector(DDTraceId.ONE), null, null, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java index 6cb01e76f08..235b21d4799 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/taginterceptor/TagInterceptorTest.java @@ -1069,7 +1069,8 @@ void exactlyTheTagsWithTheInterceptedBitHaveACase() throws IllegalAccessExceptio boolean hasCase = consumed || !mockingDetails(span).getInvocations().isEmpty(); assertEquals(KnownTagCodec.isIntercepted(tagId), hasCase, field.getName()); - assertEquals(KnownTagCodec.isIntercepted(tagId), interceptor.needsIntercept(tagId)); + assertEquals( + KnownTagCodec.isIntercepted(tagId), interceptor.needsIntercept(tagId), field.getName()); } } diff --git a/gradle/forbiddenApiFilters/instrumentation.txt b/gradle/forbiddenApiFilters/instrumentation.txt index 5ffb1f7868d..102a57de753 100644 --- a/gradle/forbiddenApiFilters/instrumentation.txt +++ b/gradle/forbiddenApiFilters/instrumentation.txt @@ -3,7 +3,13 @@ @defaultMessage Use AgentSpan#setServiceName(String, CharSequence) instead to track the source of the service name. The source should typically be the instrumentation name. datadog.trace.bootstrap.instrumentation.api.AgentSpan#setServiceName(java.lang.String) -# TagMap.internals() skips checks the tracer core has already made; it is @Restricted to the core -# (see datadog.apicontract.Restricted). Instrumentation uses TagMap's checked methods. -@defaultMessage TagMap.internals() is restricted to the tracer core; use TagMap's checked methods instead. -datadog.trace.api.TagMap#internals() +# A span's tag stores, how it gets them from a parent, and the span's view of them skip checks the tracer +# core has already made; they are @Restricted to the core (see datadog.apicontract.Restricted). +# Instrumentation uses TagMap's checked methods. +@defaultMessage Tag stores are restricted to the tracer core; use TagMap's checked methods instead. +datadog.trace.api.HashStore +datadog.trace.api.IdStore +datadog.trace.api.TagMap#readThroughParent(datadog.trace.api.TagMap) +datadog.trace.api.TagMap#viewOf(datadog.trace.api.IdStore,datadog.trace.api.HashStore,boolean) +datadog.trace.api.TagMap#customStore() +datadog.trace.api.TagMap#isCustomStoreBorrowed() diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 3d81035b6a8..bb0e9803eac 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -703,6 +703,7 @@ import static datadog.trace.api.config.TracerConfig.TRACE_CLOUD_PAYLOAD_TAGGING_SERVICES; import static datadog.trace.api.config.TracerConfig.TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING; import static datadog.trace.api.config.TracerConfig.TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING; +import static datadog.trace.api.config.TracerConfig.TRACE_DENSE_TAGS_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_EXPERIMENTAL_FEATURES_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_GIT_METADATA_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_HTTP_CLIENT_ERROR_STATUSES; @@ -1462,6 +1463,7 @@ public static String getHostName() { private final boolean jdkSocketEnabled; private final boolean spanBuilderReuseEnabled; + private final boolean traceDenseTagsEnabled; private final int tagNameUtf8CacheSize; private final int tagValueUtf8CacheSize; private final int stackTraceLengthLimit; @@ -3455,6 +3457,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) this.spanBuilderReuseEnabled = configProvider.getBoolean(GeneralConfig.SPAN_BUILDER_REUSE_ENABLED, true); + this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, false); this.tagNameUtf8CacheSize = Math.max(configProvider.getInteger(GeneralConfig.TAG_NAME_UTF8_CACHE_SIZE, 128), 0); this.tagValueUtf8CacheSize = @@ -5432,6 +5435,10 @@ public boolean isJdkSocketEnabled() { return jdkSocketEnabled; } + public boolean isTraceDenseTagsEnabled() { + return traceDenseTagsEnabled; + } + public boolean isSpanBuilderReuseEnabled() { return spanBuilderReuseEnabled; } diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java new file mode 100644 index 00000000000..6d2d35f5be6 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -0,0 +1,689 @@ +package datadog.trace.api; + +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.Arrays; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * The dense {@link IdStore}: known tags in two parallel arrays -- each tag's 16-bit serial and its + * value -- with no Entry per tag, reading through to the frozen parent's store. + * + *

A lookup is a linear scan of the serials, skipped when the slot and level masks rule the tag + * out. The full id is rebuilt from the serial ({@link KnownTagCodec#idOf}) only where a reader asks + * for it. Values are boxed: a primitive set by id is stored as its wrapper. + * + *

Point reads that need only the value ({@link #getValue}) build nothing. Those that return a + * reader or an Entry build their own ({@link #getEntryReader}, {@link #getEntry}), so concurrent + * reads of a frozen store shared between threads never share state. Only writes answer their prior + * value with this store's one reused flyweight, valid until the store's next operation (see {@link + * IdStore}). + * + *

Removing a tag that hides nothing inherited swaps the last slot into its place. Removing a tag + * the parent chain also holds stores the shared {@link #TOMBSTONE} as its value instead, and sets + * its slot in the tombstone mask. A tombstone is never counted, iterated or returned. + */ +final class DenseIdStore extends IdStore { + /** A value hiding an inherited tag. Never escapes the store. */ + private static final Object TOMBSTONE = new Object(); + + /** First capacity, on a store's first tag. */ + static final int INIT_CAP = 12; + + private static final char[] NO_SERIALS = new char[0]; + private static final Object[] NO_VALUES = new Object[0]; + + /** + * The reused flyweight a dense store answers with: a known tag's id and value, with no Entry. Its + * name is resolved from the id only when asked for. + */ + static class DenseReader implements EntryReader { + private long tagId; + private Object value; + private String tag; // resolved lazily via nameOf on first tag() access + + void set(long tagId, Object value) { + this.tagId = tagId; + this.value = value; + this.tag = null; + } + + @Override + public final String tag() { + String tag = this.tag; + if (tag == null) { + tag = KnownTagCodec.nameOf(this.tagId); + this.tag = tag; + } + return tag; + } + + @Override + public final long tagId() { + return this.tagId; + } + + @Override + public final byte type() { + return TagValueConversions.typeOf(this.value); + } + + @Override + public final boolean is(byte type) { + return TagValueConversions.isA(this.value, type); + } + + @Override + public final boolean isNumber() { + return TagValueConversions.isNumber(this.value); + } + + @Override + public final boolean isNumericPrimitive() { + return TagValueConversions.isNumericPrimitive(this.value); + } + + @Override + public final boolean isObject() { + return TagValueConversions.isObject(this.value); + } + + @Override + public final boolean booleanValue() { + return TagValueConversions.toBoolean(this.value); + } + + @Override + public final int intValue() { + return TagValueConversions.toInt(this.value); + } + + @Override + public final long longValue() { + return TagValueConversions.toLong(this.value); + } + + @Override + public final float floatValue() { + return TagValueConversions.toFloat(this.value); + } + + @Override + public final double doubleValue() { + return TagValueConversions.toDouble(this.value); + } + + @Override + public final String stringValue() { + return TagValueConversions.toString(this.value); + } + + @Override + public final Object objectValue() { + return this.value; + } + + @Override + public final TagMap.Entry entry() { + return TagMap.Entry.newAnyEntry(this.tagId, this.value); + } + + @Override + public final Map.Entry mapEntry() { + return this.entry(); + } + } + + /** + * Walks the leaf's slots, then each ancestor's, skipping tombstones and shadowed tags. The cursor + * is its own flyweight: {@link #next} points it at the next visible tag and returns it. + */ + private static final class DenseCursor extends DenseReader implements Cursor { + private final DenseIdStore leaf; + private DenseIdStore level; + private int index = -1; + + DenseCursor(DenseIdStore leaf) { + this.leaf = leaf; + this.level = leaf; + } + + @Override + public EntryReader next() { + DenseIdStore level = this.level; + while (level != null) { + for (++this.index; this.index < level.used; ++this.index) { + if (this.leaf.readIfVisible(level, this.index, this)) { + return this; + } + } + level = level.parent; + this.level = level; + this.index = -1; + } + return null; + } + } + + /** The serial of each local tag or tombstone, in the first {@link #used} slots. */ + private char[] serials = NO_SERIALS; + + private Object[] values = NO_VALUES; + + /** Slots in use, tombstones included. */ + private int used; + + /** Live local tags, not counting tombstones. */ + private int count; + + /** The frozen parent's id store, or null. */ + final DenseIdStore parent; + + /** + * The reused flyweight that writes ({@code put}, {@code getAndPut}, {@code remove}) answer their + * prior value with; built on first use. Only writes use it: a store being written has a single + * writer, while a frozen store that threads share is only read, and reads build their own. + */ + private DenseReader reader; + + DenseIdStore(IdStore parent) { + super(parent); + this.parent = (DenseIdStore) parent; + } + + /** + * A fresh reader per call, never the store's reused one: point reads can come from several + * threads at once on a frozen store they share. A short-lived reader is usually scalar-replaced. + */ + @Override + public EntryReader getEntryReader(long tagId) { + Object value = this.getValue(tagId); + if (value == null) { + return null; + } + DenseReader reader = new DenseReader(); + reader.set(tagId, value); + return reader; + } + + @Override + public TagMap.Entry getEntry(long tagId) { + Object value = this.getValue(tagId); + return value == null ? null : TagMap.Entry.newAnyEntry(tagId, value); + } + + @Override + public Object getValue(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + return this.getVisible((char) KnownTagCodec.serialNum(tagId), slotBit(tagId), levelOf(tagId)); + } + + @Override + public String getString(long tagId) { + Object value = this.getValue(tagId); + return value == null ? null : value.toString(); + } + + /** + * The visible value of the tag {@code serial} in the slot {@code bit} of {@code level}, or null. + */ + private Object getVisible(char serial, long bit, int level) { + if (this.mayHaveLocal(bit, level)) { + int i = this.indexOf(serial); + if (i >= 0) { + Object value = this.values[i]; + return value == TOMBSTONE ? null : value; + } + } + return this.mayInherit(bit, level) ? this.parent.getVisible(serial, bit, level) : null; + } + + @Override + public void set(long tagId, Object value) { + this.store(tagId, value); + } + + @Override + public void set(long tagId, CharSequence value) { + this.store(tagId, value); + } + + @Override + public void set(long tagId, boolean value) { + this.store(tagId, Boolean.valueOf(value)); + } + + @Override + public void set(long tagId, int value) { + this.store(tagId, Integer.valueOf(value)); + } + + @Override + public void set(long tagId, long value) { + this.store(tagId, Long.valueOf(value)); + } + + @Override + public void set(long tagId, float value) { + this.store(tagId, Float.valueOf(value)); + } + + @Override + public void set(long tagId, double value) { + this.store(tagId, Double.valueOf(value)); + } + + @Override + public EntryReader put(EntryReader entryReader) { + Object value = entryReader.objectValue(); + if (value == null) { + return null; // a reader with nothing to store is a no-op, as for TagMap.set + } + long tagId = entryReader.tagId(); + return this.readerOf(tagId, this.store(tagId, value)); + } + + @Override + public EntryReader getAndPut(EntryReader entryReader) { + if (entryReader.objectValue() == null) { + return null; // a reader with nothing to store is a no-op + } + long tagId = entryReader.tagId(); + char serial = (char) KnownTagCodec.serialNum(tagId); + long bit = slotBit(tagId); + int level = levelOf(tagId); + int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1; + Object prior; + if (i >= 0) { + prior = this.values[i]; + if (prior == TOMBSTONE) { + prior = null; // tombstoned, so not visible + this.markLocal(bit, level); + this.count++; + } + this.values[i] = entryReader.objectValue(); + } else { + prior = this.mayInherit(bit, level) ? this.parent.getVisible(serial, bit, level) : null; + this.append(serial, bit, level, entryReader.objectValue()); + } + return this.readerOf(tagId, prior); + } + + @Override + public EntryReader remove(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + char serial = (char) KnownTagCodec.serialNum(tagId); + long bit = slotBit(tagId); + int level = levelOf(tagId); + + int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1; + Object local = null; + if (i >= 0) { + local = this.values[i]; + if (local == TOMBSTONE) { + return null; // already removed + } + } + if (this.mayInherit(bit, level)) { + // Read-through: hide an inherited value with a tombstone. The prior *visible* value is the + // local one if there was one, otherwise the inherited one. + Object inherited = this.parent.getVisible(serial, bit, level); + if (inherited != null) { + if (i >= 0) { + this.values[i] = TOMBSTONE; + this.count--; + } else { + this.appendSlot(serial, TOMBSTONE); + } + this.markTombstone(bit, level); + return this.readerOf(tagId, local != null ? local : inherited); + } + } + if (i >= 0) { + this.removeAt(i); + this.count--; + } + return this.readerOf(tagId, local); + } + + /** Sets a known tag's value, returning the prior local value, or null. Builds no Entry. */ + private Object store(long tagId, Object value) { + char serial = (char) KnownTagCodec.serialNum(tagId); + long bit = slotBit(tagId); + int level = levelOf(tagId); + int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1; + if (i >= 0) { + Object prior = this.values[i]; + this.values[i] = value; + if (prior == TOMBSTONE) { + this.markLocal(bit, level); + this.count++; // a tombstone replaced is a removal undone: no prior local value + return null; + } + return prior; + } + this.append(serial, bit, level, value); + return null; + } + + /** Appends a live local tag. */ + private void append(char serial, long bit, int level, Object value) { + this.appendSlot(serial, value); + this.markLocal(bit, level); + this.count++; + } + + private void appendSlot(char serial, Object value) { + int i = this.used; + if (i == this.serials.length) { + this.grow(); + } + this.serials[i] = serial; + this.values[i] = value; + this.used = i + 1; + } + + /** Removes slot {@code i}, swapping the last slot into its place. The masks stay as they were. */ + private void removeAt(int i) { + int last = --this.used; + this.serials[i] = this.serials[last]; + this.values[i] = this.values[last]; + this.serials[last] = 0; + this.values[last] = null; + } + + /** Index of {@code serial} among the used slots, or -1. */ + private int indexOf(char serial) { + char[] serials = this.serials; + for (int i = 0; i < this.used; ++i) { + if (serials[i] == serial) return i; + } + return -1; + } + + /** + * Makes room for one more slot: the first from the shared empty arrays at {@link #INIT_CAP}, then + * doubling, bounded by {@link KnownTagCodec#serialLimit()} -- a store holds each known tag at + * most once. Out of line, as it is rare after the first. + */ + private void grow() { + int length = this.serials.length; + if (length == 0) { + this.serials = new char[INIT_CAP]; + this.values = new Object[INIT_CAP]; + return; + } + int newCap = Math.max(Math.min(length << 1, KnownTagCodec.serialLimit()), length + 1); + this.serials = Arrays.copyOf(this.serials, newCap); + this.values = Arrays.copyOf(this.values, newCap); + } + + /** The reused flyweight over a write's prior {@code value}, or null when there is none. */ + private EntryReader readerOf(long tagId, Object value) { + if (value == null) { + return null; + } + DenseReader reader = this.reader; + if (reader == null) { + reader = new DenseReader(); + this.reader = reader; + } + reader.set(tagId, value); + return reader; + } + + @Override + int size() { + return this.parent == null ? this.count : this.count + this.visibleParentCount(); + } + + /** Ancestor tags not shadowed or tombstoned by a nearer level, nearest-level-wins. */ + private int visibleParentCount() { + int count = 0; + for (DenseIdStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + for (int i = 0; i < ancestor.used; ++i) { + if (ancestor.values[i] != TOMBSTONE && this.visibleFrom(ancestor, i)) { + count++; + } + } + } + return count; + } + + @Override + boolean isEmpty() { + if (this.count != 0) { + return false; + } + DenseIdStore parent = this.parent; + if (parent == null) { + return true; + } + if (this.tombstoneMask == 0L) { + return parent.isEmpty(); // nothing hidden: the parent's tags are all visible + } + return this.visibleParentCount() == 0; + } + + @Override + boolean isDefinitelyEmpty() { + for (DenseIdStore level = this; level != null; level = level.parent) { + if (level.count != 0) { + return false; + } + } + return true; + } + + @Override + int estimateSize() { + int total = 0; + for (DenseIdStore level = this; level != null; level = level.parent) { + total += level.count; + } + return total; + } + + @Override + boolean hasParent() { + return this.parent != null; + } + + /** + * Whether slot {@code i} of the ancestor level {@code fromAncestor} is visible through this leaf: + * no nearer level holds a value or a tombstone for its tag. A nearer level whose masks rule the + * slot out is skipped without a scan. + */ + private boolean visibleFrom(DenseIdStore fromAncestor, int i) { + char serial = fromAncestor.serials[i]; + long tagId = KnownTagCodec.idOf(serial); + long bit = slotBit(tagId); + int level = levelOf(tagId); + for (DenseIdStore nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + if (nearer.mayHaveLocal(bit, level) && nearer.indexOf(serial) >= 0) { + return false; + } + } + return true; + } + + /** Points {@code reader} at slot {@code i} of {@code level}, if it holds a visible tag. */ + private boolean readIfVisible(DenseIdStore level, int i, DenseReader reader) { + Object value = level.values[i]; + if (value == TOMBSTONE || (level != this && !this.visibleFrom(level, i))) { + return false; + } + reader.set(KnownTagCodec.idOf(level.serials[i]), value); + return true; + } + + @Override + void forEach(Consumer consumer) { + DenseReader reader = new DenseReader(); + for (DenseIdStore level = this; level != null; level = level.parent) { + for (int i = 0; i < level.used; ++i) { + if (this.readIfVisible(level, i, reader)) { + consumer.accept(reader); + } + } + } + } + + @Override + void forEach(T thisObj, BiConsumer consumer) { + DenseReader reader = new DenseReader(); + for (DenseIdStore level = this; level != null; level = level.parent) { + for (int i = 0; i < level.used; ++i) { + if (this.readIfVisible(level, i, reader)) { + consumer.accept(thisObj, reader); + } + } + } + } + + @Override + void forEach(T thisObj, U otherObj, TriConsumer consumer) { + DenseReader reader = new DenseReader(); + for (DenseIdStore level = this; level != null; level = level.parent) { + for (int i = 0; i < level.used; ++i) { + if (this.readIfVisible(level, i, reader)) { + consumer.accept(thisObj, otherObj, reader); + } + } + } + } + + @Override + Cursor cursor() { + return new DenseCursor(this); + } + + @Override + IdStore copy() { + DenseIdStore copy = new DenseIdStore(this.parent); + if (this.used != 0) { + copy.serials = Arrays.copyOf(this.serials, this.serials.length); + copy.values = Arrays.copyOf(this.values, this.values.length); + } + copy.used = this.used; + copy.count = this.count; + copy.spanOccupancy = this.spanOccupancy; + copy.traceOccupancy = this.traceOccupancy; + copy.tombstoneMask = this.tombstoneMask; + copy.tombstoneLevels = this.tombstoneLevels; + return copy; + } + + @Override + void putAllLocal(IdStore other) { + DenseIdStore that = (DenseIdStore) other; + if (that.count == 0) { + return; + } + if (this.used == 0 && that.used == that.count) { + // nothing here and no tombstones there: take copies of its arrays + this.serials = Arrays.copyOf(that.serials, that.serials.length); + this.values = Arrays.copyOf(that.values, that.values.length); + this.used = that.used; + this.count = that.count; + this.spanOccupancy |= that.spanOccupancy; + this.traceOccupancy |= that.traceOccupancy; + return; + } + for (int i = 0; i < that.used; ++i) { + Object value = that.values[i]; + if (value != TOMBSTONE) { + this.store(KnownTagCodec.idOf(that.serials[i]), value); + } + } + } + + @Override + void fillMapLocal(Map map) { + for (int i = 0; i < this.used; ++i) { + Object value = this.values[i]; + if (value != TOMBSTONE) { + map.put(nameAt(i), value); + } + } + } + + @Override + void fillStringMapLocal(Map map) { + for (int i = 0; i < this.used; ++i) { + Object value = this.values[i]; + if (value != TOMBSTONE) { + map.put(nameAt(i), TagValueConversions.toString(value)); + } + } + } + + private String nameAt(int i) { + return KnownTagCodec.nameOf(KnownTagCodec.idOf(this.serials[i])); + } + + @Override + void checkIntegrity() { + if (this.used > this.serials.length || this.serials.length != this.values.length) { + throw new IllegalStateException("incorrect capacity"); + } + for (int i = 0; i < this.used; ++i) { + char serial = this.serials[i]; + if (serial == 0 || serial >= KnownTagCodec.serialLimit()) { + throw new IllegalStateException("not a known serial: " + (int) serial); + } + long tagId = KnownTagCodec.idOf(serial); + boolean tombstone = this.values[i] == TOMBSTONE; + long mask = tombstone ? this.tombstoneMask : this.occupancy(levelOf(tagId)); + if ((mask & slotBit(tagId)) == 0L) { + throw new IllegalStateException("slot bit not set: " + nameAt(i)); + } + if (tombstone && (this.tombstoneLevels & levelOf(tagId)) == 0) { + throw new IllegalStateException("level not set: " + nameAt(i)); + } + if (this.values[i] == null) { + throw new IllegalStateException("null value: " + nameAt(i)); + } + for (int j = i + 1; j < this.used; ++j) { + if (this.serials[j] == serial) { + throw new IllegalStateException("duplicate tag: " + nameAt(i)); + } + } + } + if (this.count != this.computeSize()) { + throw new IllegalStateException("incorrect size"); + } + } + + @Override + int computeSize() { + int live = 0; + for (int i = 0; i < this.used; ++i) { + if (this.values[i] != TOMBSTONE) live++; + } + return live; + } + + @Override + String toInternalString() { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < this.used; ++i) { + Object value = this.values[i]; + builder + .append('[') + .append(i) + .append("]: ") + .append(nameAt(i)) + .append('=') + .append(value == TOMBSTONE ? "" : value) + .append('\n'); + } + return builder.toString(); + } +} 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..32c065fa8b2 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -0,0 +1,425 @@ +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. + * + *

A read checks the slot and level masks ({@link IdStore}) before probing: a clear local bit + * skips the buckets, and a clear inherited bit skips the read-through. Removing an inherited tag + * puts that tag's shared tombstone ({@link Entry#tombstoneOf}) in the local buckets, so it + * allocates nothing, and sets its slot in the tombstone mask; the bucket entry is the exact state, + * the mask the hint. 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; + + HashIdStore(IdStore parent) { + super(parent); + this.buckets = HashSupport.newBuckets(); + this.parent = (HashIdStore) parent; + } + + private HashIdStore(Object[] buckets) { + super(null); + this.buckets = buckets; + this.parent = null; + } + + @Override + public Entry getEntry(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + return this.getVisible(tagId, slotBit(tagId), levelOf(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 and its slot bit and level. */ + private Entry getVisible(long tagId, long bit, int level) { + if (this.mayHaveLocal(bit, level)) { + Entry local = HashSupport.findById(this.buckets, Entry.bucketHash(tagId), tagId); + if (local != null) { + return local.tombstone ? null : local; + } + } + return this.mayInherit(bit, level) ? this.parent.getVisible(tagId, bit, level) : null; + } + + @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) { + this.put(Entry.newAnyEntry(tagId, value)); + } + + @Override + public void set(long tagId, CharSequence value) { + 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); + this.markLocal(slotBit(entry.tagHash), levelOf(entry.tagHash)); + 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 + } + long tagId = entry.tagHash; + long bit = slotBit(tagId); + int level = levelOf(tagId); + Entry replaced = HashSupport.put(this.buckets, entry); + this.markLocal(bit, level); + if (replaced != null && !replaced.tombstone) { + return replaced; // replaced a local entry -> that is the prior value + } + this.count++; + if (replaced != null || !this.mayInherit(bit, level)) { + return null; // it was tombstoned, so not visible, or nothing to inherit + } + return this.parent.getVisible(tagId, bit, level); + } + + @Override + public Entry remove(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + long bit = slotBit(tagId); + int level = levelOf(tagId); + int hash = Entry.bucketHash(tagId); + + Entry local = + this.mayHaveLocal(bit, level) ? HashSupport.findById(this.buckets, hash, tagId) : null; + if (local != null) { + if (local.tombstone) { + return null; // already removed + } + HashSupport.remove(this.buckets, hash, local.tag); + this.count--; + } + if (this.mayInherit(bit, level)) { + // 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 = this.parent.getVisible(tagId, bit, level); + if (inherited != null) { + HashSupport.put(this.buckets, Entry.tombstoneOf(tagId)); + this.markTombstone(bit, level); + 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.tombstoneMask == 0L) { + 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. A nearer level whose masks + * rule the slot out is skipped without a probe. + */ + boolean parentEntryVisible(Entry parentEntry, HashIdStore fromAncestor) { + long tagId = parentEntry.tagHash; + long bit = slotBit(tagId); + int level = levelOf(tagId); + int hash = parentEntry.hash(); + for (HashIdStore nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + if (nearer.mayHaveLocal(bit, level) + && HashSupport.findById(nearer.buckets, hash, tagId) != null) { + return false; + } + } + return true; + } + + @Override + void forEach(Consumer 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.spanOccupancy = this.spanOccupancy; + copy.traceOccupancy = this.traceOccupancy; + copy.tombstoneMask = this.tombstoneMask; + copy.tombstoneLevels = this.tombstoneLevels; + return copy; + } + + @Override + void putAllLocal(IdStore other) { + HashIdStore that = (HashIdStore) other; + if (that.count == 0) { + return; + } + if (this.tombstoneMask != 0L) { + // 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); + } + this.spanOccupancy |= that.spanOccupancy; + this.traceOccupancy |= that.traceOccupancy; + } + + @Override + void fillMapLocal(Map map) { + HashSupport.fillMap(this.buckets, map); + } + + @Override + void fillStringMapLocal(Map 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..8b526d385a0 --- /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; + + /** + * {@code parent}'s custom store, for an owner to borrow read-only until its first custom write, + * or null. + */ + public static HashStore inheritedFrom(TagMap parent) { + return parent == null ? null : parent.nameStore; + } + + /** + * 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) { + this.put(Entry.newAnyEntry(tag, value)); + } + + public void set(String tag, CharSequence value) { + 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 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 map) { + HashSupport.fillMap(this.buckets, map); + } + + void fillStringMapLocal(Map 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 new file mode 100644 index 00000000000..ee6749e1e4e --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -0,0 +1,466 @@ +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 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 -- 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() { + return new Object[1 << 4]; + } + + /** 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); + } + + /** + * {@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.hash() == hash && tagEntry.matches(tag) ? tagEntry : null; + } else if (bucket instanceof BucketGroup) { + return ((BucketGroup) bucket).findInChain(hash, tag); + } + return null; + } + + /** Inserts or replaces {@code newEntry}, returning the entry it replaced, or null if it added. */ + static Entry put(Object[] buckets, Entry newEntry) { + Object[] thisBuckets = buckets; + + int newHash = newEntry.hash(); + int bucketIndex = newHash & (thisBuckets.length - 1); + + Object bucket = thisBuckets[bucketIndex]; + if (bucket == null) { + thisBuckets[bucketIndex] = newEntry; + + return null; + } else if (bucket instanceof Entry) { + Entry existingEntry = (Entry) bucket; + if (existingEntry.hash() == newHash && existingEntry.matches(newEntry.tag)) { + thisBuckets[bucketIndex] = newEntry; + + // replaced existing entry - no size change + return existingEntry; + } else { + thisBuckets[bucketIndex] = + new BucketGroup(existingEntry.hash(), existingEntry, newHash, newEntry); + + return null; + } + } else if (bucket instanceof BucketGroup) { + BucketGroup lastGroup = (BucketGroup) bucket; + + BucketGroup containingGroup = lastGroup.findContainingGroupInChain(newHash, newEntry.tag); + if (containingGroup != null) { + // replaced existing entry - no size change + return containingGroup._replace(newHash, newEntry); + } + + if (!lastGroup.insertInChain(newHash, newEntry)) { + thisBuckets[bucketIndex] = new BucketGroup(newHash, newEntry, lastGroup); + } + return null; + } + + // unreachable + return null; + } + + /** 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.hash() == hash && existingEntry.matches(tag)) { + thisBuckets[bucketIndex] = null; + + return existingEntry; + } else { + return null; + } + } else if (bucket instanceof BucketGroup) { + BucketGroup lastGroup = (BucketGroup) bucket; + + BucketGroup containingGroup = lastGroup.findContainingGroupInChain(hash, tag); + if (containingGroup == null) { + return null; + } + + Entry existingEntry = containingGroup._remove(hash, tag); + if (containingGroup._isEmpty()) { + thisBuckets[bucketIndex] = lastGroup.removeGroupInChain(containingGroup); + } + + return existingEntry; + } + return null; + } + + /** + * Copies {@code thatBuckets} into the empty {@code thisBuckets}, sharing Entry objects and + * 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) { + Object thatBucket = thatBuckets[i]; + + // faster to explicitly null check first, then do instanceof + if (thatBucket == null) { + // do nothing + } else if (thatBucket instanceof BucketGroup) { + // if it is a BucketGroup, then need to clone + BucketGroup thatGroup = (BucketGroup) thatBucket; + + thisBuckets[i] = thatGroup.cloneChain(); + } else { // if ( thatBucket instanceof Entry ) + thisBuckets[i] = thatBucket; + } + } + } + + /** + * Merges {@code thatBuckets} into {@code thisBuckets}, sharing Entry objects and cloning only + * 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; + + // 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) { + Object thatBucket = thatBuckets[i]; + + // if nothing incoming, nothing to do + if (thatBucket == null) continue; + + Object thisBucket = thisBuckets[i]; + if (thisBucket == null) { + // This bucket is null, easy case + // Either copy over the sole entry or clone the BucketGroup chain + + if (thatBucket instanceof Entry) { + thisBuckets[i] = thatBucket; + added += 1; + } else if (thatBucket instanceof BucketGroup) { + BucketGroup thatGroup = (BucketGroup) thatBucket; + + BucketGroup thisNewGroup = thatGroup.cloneChain(); + thisBuckets[i] = thisNewGroup; + added += thisNewGroup.sizeInChain(); + } + } else if (thisBucket instanceof Entry) { + // This bucket is a single entry, medium complexity case + // If other side is an Entry - just merge the entries into a bucket + // If other side is a BucketGroup - then clone the group and insert the entry normally into + // the cloned group + + Entry thisEntry = (Entry) thisBucket; + int thisHash = thisEntry.hash(); + + if (thatBucket instanceof Entry) { + Entry thatEntry = (Entry) thatBucket; + int thatHash = thatEntry.hash(); + + if (thisHash == thatHash && thisEntry.matches(thatEntry.tag())) { + thisBuckets[i] = thatEntry; + // replacing entry, no size change + } else { + thisBuckets[i] = + new BucketGroup( + thisHash, thisEntry, + thatHash, thatEntry); + added += 1; + } + } else if (thatBucket instanceof BucketGroup) { + BucketGroup thatGroup = (BucketGroup) thatBucket; + + // Clone the other group, then place this entry into that group + BucketGroup thisNewGroup = thatGroup.cloneChain(); + int thisNewGroupSize = thisNewGroup.sizeInChain(); + + 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 entry, so we're done + thisBuckets[i] = thisNewGroup; + + // overlapping group - subtract one for clobbered existing entry + added += thisNewGroupSize - 1; + } else if (thisNewGroup.insertInChain(thisHash, thisEntry)) { + // able to add thisEntry into the existing groups + thisBuckets[i] = thisNewGroup; + + // 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 counted + added += thisNewGroupSize; + } + } + } else if (thisBucket instanceof BucketGroup) { + // This bucket is a BucketGroup, medium to hard case + // If the other side is an entry, just normal insertion procedure - no cloning required + BucketGroup thisGroup = (BucketGroup) thisBucket; + + if (thatBucket instanceof Entry) { + Entry thatEntry = (Entry) thatBucket; + int thatHash = thatEntry.hash(); + + if (thisGroup.replaceInChain(thatHash, thatEntry) != null) { + // replaced existing entry no size change + } else if (thisGroup.insertInChain(thatHash, thatEntry)) { + added += 1; + } else { + thisBuckets[i] = new BucketGroup(thatHash, thatEntry, thisGroup); + added += 1; + } + } else if (thatBucket instanceof BucketGroup) { + // Most complicated case - need to walk that bucket group chain and update this chain + BucketGroup thatGroup = (BucketGroup) thatBucket; + + // Taking the easy / expensive way out for updating size + int thisPrevGroupSize = thisGroup.sizeInChain(); + + BucketGroup thisNewGroup = thisGroup.replaceOrInsertAllInChain(thatGroup); + int thisNewGroupSize = thisNewGroup.sizeInChain(); + + thisBuckets[i] = thisNewGroup; + added += (thisNewGroupSize - thisPrevGroupSize); + } + } + } + return added; + } + + /** Visits every entry, skipping tombstones. */ + static void forEach(Object[] buckets, Consumer 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); + } + } + } + } + } + + static void forEach( + Object[] buckets, T thisObj, BiConsumer 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); + } + } + } + } + } + + static void forEach( + Object[] buckets, T thisObj, U otherObj, TriConsumer 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); + } + } + } + } + } + + static void fillMap(Object[] buckets, Map map) { + forEach(buckets, map, (m, entry) -> m.put(entry.tag(), entry.objectValue())); + } + + static void fillStringMap(Object[] buckets, Map map) { + forEach(buckets, map, (m, entry) -> m.put(entry.tag(), entry.stringValue())); + } + + /** The number of live entries, not counting tombstones. Tests only. */ + static int count(Object[] buckets) { + int[] count = new int[1]; + forEach(buckets, count, (c, entry) -> c[0]++); + return count[0]; + } + + /** 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) { + Object thisBucket = thisBuckets[i]; + + if (thisBucket instanceof Entry) { + Entry thisEntry = (Entry) thisBucket; + int thisHash = thisEntry.hash(); + + int expectedBucket = thisHash & (thisBuckets.length - 1); + if (expectedBucket != i) { + throw new IllegalStateException("incorrect bucket"); + } + } else if (thisBucket instanceof BucketGroup) { + BucketGroup thisGroup = (BucketGroup) thisBucket; + + for (BucketGroup curGroup = thisGroup; curGroup != null; curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry thisEntry = curGroup._entryAt(j); + if (thisEntry == null) continue; + + int thisHash = thisEntry.hash(); + assert curGroup._hashAt(j) == thisHash; + + int expectedBucket = thisHash & (thisBuckets.length - 1); + if (expectedBucket != i) { + throw new IllegalStateException("incorrect bucket"); + } + } + } + } + } + } + + static String toInternalString(Object[] buckets) { + Object[] thisBuckets = buckets; + + StringBuilder ledger = new StringBuilder(128); + for (int i = 0; i < thisBuckets.length; ++i) { + ledger.append('[').append(i).append("] = "); + + Object thisBucket = thisBuckets[i]; + if (thisBucket == null) { + ledger.append("null"); + } else if (thisBucket instanceof Entry) { + ledger.append('{').append(thisBucket).append('}'); + } else if (thisBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) thisBucket; + curGroup != null; + curGroup = curGroup.prev) { + ledger.append(curGroup).append(" -> "); + } + } + ledger.append('\n'); + } + return ledger.toString(); + } + + /** 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..fec64355c51 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -0,0 +1,262 @@ +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} loads one implementation per JVM, {@link HashIdStore} or {@link + * DenseIdStore}. With one subclass loaded, class hierarchy analysis devirtualizes every call on an + * {@code IdStore} without a type guard, so nothing that is always loaded may extend this class. No + * locking: guarded by the owner's lock. + * + *

The base keeps the slot masks an implementation shares: a tag's slot ({@link + * KnownTagCodec#slotOf}) maps it to one bit of a {@code long}. A bit is a may-be-present hint -- + * tags that don't usually co-occur share a slot -- so a clear bit is a definite miss and a set bit + * means "probe". The masks are never cleared by a removal, only widened. + * + *

Span-level and trace-level tags are colored separately and reuse the same slots, since they + * are meant to live in separate stores (a span's trace-level tags in the frozen parent it reads + * through). So each store also records which levels it holds, local and inherited, and a lookup + * consults the slot bits only for a level that is present: in a store holding one level the word is + * exact, and a store holding both still works, with a cross-level collision costing a probe. + */ +@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(); + } + + /** + * Slots that may hold a live local span-level entry. Span-level and trace-level tags are colored + * independently, so their slot numbers overlap; a local root span holds both, so each level keeps + * its own word and a span-level miss isn't defeated by a trace-level tag in the same slot. + */ + long spanOccupancy; + + /** Slots that may hold a live local trace-level entry; see {@link #spanOccupancy}. */ + long traceOccupancy; + + /** Slots that may hold a local tombstone, hiding an inherited entry. */ + long tombstoneMask; + + /** + * Slots an ancestor may hold, fixed at construction: ancestors are frozen. A clear bit lets a + * local miss skip read-through. + */ + final long inheritedMask; + + /** The levels ({@link #SPAN_LEVEL}, {@link #TRACE_LEVEL}) of the local tombstones. */ + byte tombstoneLevels; + + /** The levels an ancestor may hold, fixed at construction like {@link #inheritedMask}. */ + final byte inheritedLevels; + + static final int SPAN_LEVEL = 1; + static final int TRACE_LEVEL = 2; + + IdStore(IdStore parent) { + if (parent == null) { + this.inheritedMask = 0L; + this.inheritedLevels = 0; + } else { + this.inheritedMask = parent.spanOccupancy | parent.traceOccupancy | parent.inheritedMask; + this.inheritedLevels = (byte) (parent.localLevels() | parent.inheritedLevels); + } + } + + /** A fresh id store reading through to {@code parent}'s known tags (parent may be null). */ + public static IdStore create(TagMap parent) { + return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore); + } + + /** {@code tagId}'s slot bit. */ + static long slotBit(long tagId) { + return 1L << KnownTagCodec.slotOf(tagId); + } + + /** {@code tagId}'s level: {@link #SPAN_LEVEL} or {@link #TRACE_LEVEL}, without a branch. */ + static int levelOf(long tagId) { + return 1 << (int) ((tagId & KnownTagCodec.LEVEL_TRACE) >>> 2); + } + + /** The occupancy word of {@code level}: a select that folds when the level is constant. */ + final long occupancy(int level) { + return level == TRACE_LEVEL ? this.traceOccupancy : this.spanOccupancy; + } + + /** The levels of the local entries and tombstones. */ + final int localLevels() { + return (this.spanOccupancy != 0L ? SPAN_LEVEL : 0) + | (this.traceOccupancy != 0L ? TRACE_LEVEL : 0) + | this.tombstoneLevels; + } + + /** Whether the slot {@code bit} may hold a local entry or tombstone of {@code level}. */ + final boolean mayHaveLocal(long bit, int level) { + return (occupancy(level) & bit) != 0L + || ((tombstoneLevels & level) != 0 && (tombstoneMask & bit) != 0L); + } + + /** Whether an ancestor may hold a tag of {@code level} in the slot {@code bit}. */ + final boolean mayInherit(long bit, int level) { + return (inheritedLevels & level) != 0 && (inheritedMask & bit) != 0L; + } + + /** Records a live local entry in the slot {@code bit} of {@code level}. */ + final void markLocal(long bit, int level) { + if (level == TRACE_LEVEL) { + this.traceOccupancy |= bit; + } else { + this.spanOccupancy |= bit; + } + } + + /** Records a local tombstone in the slot {@code bit} of {@code level}. */ + final void markTombstone(long bit, int level) { + this.tombstoneMask |= bit; + this.tombstoneLevels |= (byte) level; + } + + /** 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 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 map); + + abstract void fillStringMapLocal(Map 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..765906c62ec --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -0,0 +1,62 @@ +package datadog.trace.api; + +/** + * Builds the {@link IdStore}s of the one implementation this JVM uses: {@link DenseIdStore} when + * the dense store is on, {@link HashIdStore} otherwise. Rollout scaffolding: the hash store stays + * one flag away until the dense store is proven. + * + *

Every store is built through {@link Default#INSTANCE}, never with {@code new}. The factory not + * chosen is loaded (to check the field's type) but never linked, so its store class is never + * loaded; with one {@code IdStore} subclass loaded, 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, chosen once by the dense-store flag. */ + static final class Default { + static final IdStoreFactory INSTANCE = + Config.get().isTraceDenseTagsEnabled() + ? DenseIdStoreFactory.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(); + } +} + +final class DenseIdStoreFactory extends IdStoreFactory { + static final DenseIdStoreFactory INSTANCE = new DenseIdStoreFactory(); + + private DenseIdStoreFactory() {} + + @Override + IdStore create(IdStore parent) { + return new DenseIdStore(parent); + } + + @Override + IdStore createEmpty() { + return new DenseIdStore(null); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java index 31d8bee4302..2b917a0e22d 100644 --- a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java +++ b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java @@ -13,13 +13,16 @@ */ public final class KnownTagCodec { /* - * tagId bit layout: [63-48 serialNum (16 bits)] [47-32 slot] [31-0 flags]. serialNum is - * globally unique per known tag and is the whole of the tag's identity — nameOf/ - * openTelemetryNameOf switch on it, and the generator emits each id as a literal. Bits [47-32] - * hold the tag's slot ({@link #slotOf}), assigned by graph coloring so tags on one span type never - * share a slot, or {@link #NO_SLOT} for a tag on no modeled span type. Of the low 32 flag bits, bit 2 is - * the trace/span LEVEL bit (set ⟹ trace-level), and bit 1 is the INTERCEPTED bit. Unknown - * (string-only) custom tags are NOT known ids — {@code keyOf} returns 0 for them. + * tagId bit layout: [63-48 serialNum (16 bits, 15 used: bit 63 is kept clear)] [47-32 slot] + * [31-0 flags]. The generator keeps bit 63 clear, so every id is positive and a negative value + * can never be an id. serialNum is globally unique per known tag and is the whole of the tag's + * identity — nameOf/openTelemetryNameOf switch on it, and the generator emits each id as a + * literal. Bits [47-32] hold the tag's slot ({@link #slotOf}), or {@link #NO_SLOT} for a tag on + * no modeled span type. Graph coloring gives tags that appear together on a span type distinct + * slots (see {@link #slotOf} for the overflow exception); trace-level tags are colored separately + * and reuse the span-level slots. Of the low 32 flag bits, bit 2 is the trace/span LEVEL bit (set + * ⟹ trace-level), and bit 1 is the INTERCEPTED bit. Unknown (string-only) custom tags are NOT + * known ids — {@code keyOf} returns 0 for them. * *

There is one id per Datadog name. A tag the conventions declare once per direction, such as * {@code peer.port}, is still one tag: only its OpenTelemetry name depends on the span's @@ -102,13 +105,13 @@ public static long traceLevel(long tagId) { } /** - * Builds an id carrying only {@code serialNum}: slot 0 and no flags, so it equals a tag's real id - * only when that tag has neither. Enough for serial-keyed lookups such as {@link #nameOf}; - * compare against a {@code KnownTags} constant, never against this. Inverse of {@link - * #serialNum}. Intended for tests and serial-keyed lookups. + * The id of the known tag with {@code serialNum}, slot and flag bits included, so it equals the + * tag's {@code KnownTags} constant and stores and looks up exactly like it. A serial outside the + * known range gets an id carrying only the serial, which is not a known tag. Inverse of {@link + * #serialNum}. */ public static long makeTagId(int serialNum) { - return (long) serialNum << 48; + return serialNum > 0 && serialNum < serialLimit() ? idOf(serialNum) : (long) serialNum << 48; } /* @@ -160,6 +163,9 @@ public interface Resolver { /** The tag's Datadog-namespace (canonical) name. */ String nameOf(long tagId); + /** The full id of the known tag with {@code serial}, which must be below the serial limit. */ + long idOf(int serial); + /** * The tag's OpenTelemetry-namespace name on spans of {@code direction}, or {@code null} when it * declares none there. {@link #DIRECTION_UNKNOWN} returns only a name that holds in every @@ -183,6 +189,15 @@ private static final class Installed { static final Resolver RESOLVER = KnownTags.RESOLVER; } + /** + * The full id of the known tag with {@code serial} (flag and slot bits included), for a store + * that keeps only the 16-bit serial. {@code serial} must be a known tag's: {@code 0 < serial < + * serialLimit()}. + */ + public static long idOf(int serial) { + return Installed.RESOLVER.idOf(serial); + } + /** The tag's canonical (Datadog-namespace) name, or {@code null} when the id is not known. */ public static String nameOf(long tagId) { return Installed.RESOLVER.nameOf(tagId); 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 be07fadbeeb..37733e11c7e 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1,7 +1,6 @@ package datadog.trace.api; import datadog.apicontract.Restricted; -import datadog.trace.api.function.NoEscape; import datadog.trace.api.function.TriConsumer; import datadog.trace.api.internal.VisibleForTesting; import java.util.AbstractCollection; @@ -9,7 +8,6 @@ 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; @@ -50,14 +48,9 @@ */ public final class TagMap implements Map, Iterable { /** Immutable empty TagMap - similar to {@link Collections#emptyMap()} */ - // Frozen view over a length-1 array: bucket masking needs a power-of-two array length (size 0 - // would fail with ArrayIndexOutOfBoundsException, size 1 works), and the private constructor - // reads no statics, so this is safe to build directly during TagMap's . - public static final TagMap EMPTY = new TagMap(new Object[1], 0); - - // Sentinel for an ID not yet resolved by the reused EntryReadingHelper. Zero denotes an unknown - // tag, so it cannot represent "not computed". - static final long TAG_ID_NOT_COMPUTED = Long.MIN_VALUE; + // Built from the JVM's chosen store's createEmpty(); IdStoreFactory explains why that is safe + // during TagMap's , whichever class initializes first. + public static final TagMap EMPTY = new TagMap(IdStoreFactory.Default.INSTANCE.createEmpty()); /** Creates a new mutable TagMap that contains the contents of map */ public static final TagMap fromMap(@Nonnull Map map) { @@ -98,19 +91,7 @@ public static final TagMap create(int size) { * 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"); - } - // Exact, not isDefinitelyEmpty(): a multi-level parent can be observationally empty while a - // local level still holds entries (all of them tombstoned by a nearer level). Dropping such a - // parent keeps isEmpty()'s no-tombstone fast path valid -- an attached parent always - // contributes at least one visible entry. - if (parent.isEmpty()) { - parent = null; - } - } - return new TagMap(parent); + return new TagMap(readThroughParent(parent)); } /** Creates a new TagMap.Ledger */ @@ -158,6 +139,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; @@ -447,6 +435,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 @@ -461,6 +484,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; @@ -471,6 +495,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; @@ -1170,49 +1195,38 @@ public EntryChange next() { } /* - * For memory efficiency, TagMap uses a rather complicated bucket system. - *

- * When there is only a single Entry in a particular bucket, the Entry is stored into the bucket directly. - *

- * Because the Entry objects can be shared between multiple TagMaps, the Entry objects cannot - * directly form a linked list to handle collisions. - *

- * Instead when multiple entries collide in the same bucket, a BucketGroup is formed to hold multiple entries. - * But a BucketGroup is only formed when a collision occurs to keep allocation low in the common case of no collisions. - *

- * For efficiency, BucketGroups are a fixed size, so when a BucketGroup fills up another BucketGroup is formed - * to hold the additional Entry-s. And the BucketGroup-s are connected via a linked list instead of the Entry-s. - *

- * This does introduce some inefficiencies when Entry-s are removed. - * The assumption is that removals are rare, so BucketGroups are never consolidated. - * However as a precaution if a BucketGroup becomes completely empty, then that BucketGroup will be - * removed from the collision chain. + * A TagMap holds its tags in two stores: an IdStore with the known tags by id, and a HashStore + * with the custom tags by name. Their key spaces are disjoint, + * so each store reads through to its own parent chain and keeps its own tombstones, and + * precedence never crosses stores. + * + * The custom store is created on the first custom write. Until then, a map with a parent borrows + * the parent's custom store, read-only (nameStoreBorrowed): the parent is frozen, so its store is + * stable, and with no local custom state the borrowed store reads exactly as an own one would. */ - final Object[] buckets; - private int size; - private boolean frozen; + /** The known tags, by id. Not final only so {@link #clear()} can replace it. */ + IdStore knownStore; /** - * 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. + * The custom tags, by name: null until the first custom write, or the parent's custom store, + * borrowed read-only while {@link #nameStoreBorrowed}. */ - @VisibleForTesting TagMap parent; + HashStore nameStore; + + /** True while {@link #nameStore} is the parent's custom store, borrowed until the first write. */ + boolean nameStoreBorrowed; + + private boolean frozen; /** - * 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. + * Optional frozen parent for read-through. Reads that miss locally fall through to the parent + * chain (each store reads through to the parent's matching store). 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. */ - private Set removedFromParent; + @VisibleForTesting TagMap parent; public TagMap() { this((TagMap) null); @@ -1224,93 +1238,149 @@ public TagMap() { * 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.knownStore = IdStore.create(parent); + this.nameStore = HashStore.inheritedFrom(parent); + this.nameStoreBorrowed = this.nameStore != null; this.frozen = false; this.parent = parent; } - /** Used for inexpensive immutable */ - private TagMap(Object[] buckets, int size) { - this.buckets = buckets; - this.size = size; + /** A map over {@code knownStore} and {@code nameStore}: a copy ({@link #copy()}) or a view. */ + private TagMap( + TagMap parent, IdStore knownStore, HashStore nameStore, boolean nameStoreBorrowed) { + this.knownStore = knownStore; + this.nameStore = nameStore; + this.nameStoreBorrowed = nameStoreBorrowed; + this.frozen = false; + this.parent = parent; + } + + /** A frozen map over the empty parentless store {@code knownStore}: {@link #EMPTY}. */ + private TagMap(IdStore knownStore) { + this.knownStore = knownStore; + this.nameStore = null; + this.nameStoreBorrowed = false; this.frozen = true; this.parent = null; } - @Override - public int 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(); + /** + * A live view over a span's stores, for the span's internal readers (tag post-processors, + * serializers) and its snapshot copy; never on the span's own set/get paths. Not thread-safe: use + * it under the span's tag lock. + * + *

The span owns the stores, and the view keeps the ones it was created over. A span that later + * creates its own custom store drops its view and creates a new one. After using the view, the + * span adopts any custom store the view created ({@link #customStore}), since a reader may write + * a custom tag. + */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public static TagMap viewOf(IdStore knownStore, HashStore nameStore, boolean nameStoreBorrowed) { + return new TagMap(null, knownStore, nameStore, nameStoreBorrowed); } /** - * 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. + * The parent an owner reads through to: {@code parent}, or null when it is null or empty, as + * {@link #createFromParent} decides. + * + * @throws IllegalStateException if {@code parent} is not frozen */ - 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++; - } - } - } + @Restricted(allowedIn = {"datadog.trace.core"}) + public static TagMap readThroughParent(TagMap parent) { + if (parent == null) { + return null; + } + if (!parent.isFrozen()) { + throw new IllegalStateException("read-through parent must be frozen"); + } + // Exact, not isDefinitelyEmpty(): a multi-level parent can be observationally empty while a + // local level still holds entries (all of them tombstoned by a nearer level). Dropping such a + // parent keeps isEmpty()'s no-tombstone fast path valid -- an attached parent always + // contributes at least one visible entry. + return parent.isEmpty() ? null : parent; + } + + /** + * The Entry an id store's {@code reader} denotes, or null, for a caller that keeps it or hands it + * out. Free for a hash store's reader, which is an Entry; for a dense store's reused flyweight, + * this builds one. + */ + private static Entry entryOf(EntryReader reader) { + return reader == null ? null : reader.entry(); + } + + /** This map's custom store: null, borrowed ({@link #isCustomStoreBorrowed}), or its own. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public HashStore customStore() { + return this.nameStore; + } + + /** Whether {@link #customStore} is the parent's, borrowed read-only. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public boolean isCustomStoreBorrowed() { + return this.nameStoreBorrowed; + } + + /** The custom store to write to: created on the first write, ending any borrowing. */ + HashStore nameStoreForWrite() { + HashStore nameStore = this.nameStore; + if (nameStore == null || this.nameStoreBorrowed) { + // A borrowed store is the nearest ancestor's custom store: read through to it. + nameStore = HashStore.create(nameStore); + this.nameStore = nameStore; + this.nameStoreBorrowed = false; + } + return nameStore; + } + + /** Removes the custom tag {@code tag}, returning the prior visible entry. */ + Entry removeCustom(String tag) { + HashStore nameStore = this.nameStore; + if (nameStore == null) { + return null; + } + if (this.nameStoreBorrowed) { + if (nameStore.get(tag) == null) { + return null; // nothing to hide: keep borrowing } + nameStore = this.nameStoreForWrite(); // hiding an inherited tag needs a tombstone of our own } - return count; + return nameStore.remove(tag); + } + + /** Whether either store reads through to a parent. */ + private boolean readsThrough() { + HashStore nameStore = this.nameStore; + return this.knownStore.hasParent() + || this.nameStoreBorrowed + || (nameStore != null && nameStore.parent != null); + } + + @Override + public int size() { + // Exact (Map contract). Under read-through resolves the union; prefer estimateSize() for hints. + HashStore nameStore = this.nameStore; + return this.knownStore.size() + (nameStore == null ? 0 : nameStore.size()); } @Override public boolean isEmpty() { // 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) { - // A live parent is never definitely-empty: createFromParent drops an empty parent (and copy() - // only forwards an already-vetted parent), and the parent is frozen so it can't become empty. - // So with no local entries and no tombstones, the parent contributes at least one visible - // entry -> this map is non-empty. - return false; - } - // size == 0 with tombstones (rare): empty iff every visible ancestor entry is tombstoned - return this.visibleParentCount() == 0; + HashStore nameStore = this.nameStore; + return this.knownStore.isEmpty() && (nameStore == null || nameStore.isEmpty()); } 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; + HashStore nameStore = this.nameStore; + return this.knownStore.isDefinitelyEmpty() + && (nameStore == null || nameStore.isDefinitelyEmpty()); } 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; + HashStore nameStore = this.nameStore; + return this.knownStore.estimateSize() + (nameStore == null ? 0 : nameStore.estimateSize()); } @Deprecated @@ -1321,15 +1391,31 @@ public Object get(Object tag) { return this.getObject((String) tag); } - /** Provides the corresponding entry value as an Object - boxing if necessary */ + /** + * Provides the corresponding entry value as an Object - boxing if necessary. A known tag is read + * straight from its store, so a store without an Entry per tag builds none. + */ public Object getObject(String tag) { - Entry entry = this.getEntry(tag); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return this.knownStore.getValue(key); + } + HashStore nameStore = this.nameStore; + Entry entry = nameStore == null ? null : nameStore.get(tag); return entry == null ? null : entry.objectValue(); } - /** Provides the corresponding entry value as a String - calling toString if necessary */ + /** + * Provides the corresponding entry value as a String - calling toString if necessary. Like {@link + * #getObject}, a known tag is read straight from its store. + */ public String getString(String tag) { - Entry entry = this.getEntry(tag); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return this.knownStore.getString(key); + } + HashStore nameStore = this.nameStore; + Entry entry = nameStore == null ? null : nameStore.get(tag); return entry == null ? null : entry.stringValue(); } @@ -1338,8 +1424,8 @@ public boolean getBoolean(String tag) { } public boolean getBooleanOrDefault(String tag, boolean defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.booleanValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.booleanValue(); } public int getInt(String tag) { @@ -1347,8 +1433,8 @@ public int getInt(String tag) { } public int getIntOrDefault(String tag, int defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.intValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.intValue(); } public long getLong(String tag) { @@ -1356,8 +1442,8 @@ public long getLong(String tag) { } public long getLongOrDefault(String tag, long defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.longValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.longValue(); } public float getFloat(String tag) { @@ -1365,8 +1451,8 @@ public float getFloat(String tag) { } public float getFloatOrDefault(String tag, float defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.floatValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.floatValue(); } public double getDouble(String tag) { @@ -1374,15 +1460,15 @@ public double getDouble(String tag) { } public double getDoubleOrDefault(String tag, double defaultValue) { - Entry entry = this.getEntry(tag); - return entry == null ? defaultValue : entry.doubleValue(); + EntryReader reader = this.getEntryReader(tag); + return reader == null ? defaultValue : reader.doubleValue(); } @Override public boolean containsKey(Object key) { if (!(key instanceof String)) return false; - return (this.getEntry((String) key) != null); + return (this.getEntryReader((String) key) != null); } @Override @@ -1419,84 +1505,37 @@ public Set> entrySet() { public Entry getEntry(String tag) { // Entries are stored under their canonical Datadog name (see Entry's constructor); a lookup by - // an OpenTelemetry rename must canonicalize the same way, or it would hash to the wrong bucket - // and silently miss the entry stored under the Datadog name. - long tagHash = Entry.tagHashOf(tag); - String canonicalTag = (tagHash >>> 32) != 0 ? KnownTagCodec.nameOf(tagHash) : tag; - return this.getEntry(canonicalTag, tagHash); + // an OpenTelemetry rename must resolve to the same id, or it would silently miss the entry. + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return this.knownStore.getEntry(key); + } + HashStore nameStore = this.nameStore; + return nameStore == null ? null : nameStore.get(tag); } /** The entry for a known tag id, found by the id itself rather than by resolving a name. */ public Entry getEntry(long tagId) { - return this.getEntry(Entry.requireKnownName(tagId), tagId); - } - - private Entry getEntry(String canonicalTag, long tagHash) { - Entry local = this.getLocalEntry(canonicalTag, tagHash); - 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(canonicalTag)) { - return null; // tombstoned: removed locally, do not read through - } - return parent.getEntry(canonicalTag, tagHash); - } - - /** Looks up an entry in this map's own buckets only — no read-through to the parent. */ - private Entry getLocalEntry(String tag, long tagHash) { - Object[] thisBuckets = this.buckets; - int hash = Entry.bucketHash(tagHash); - return findInBucket(thisBuckets[hash & (thisBuckets.length - 1)], hash, tag); + return this.knownStore.getEntry(tagId); } /** - * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null). + * Like {@link #getEntry(String)}, but answers with a momentary {@link EntryReader}, so a store + * without an Entry per tag builds none. The reader MUST NOT be kept (see {@link EntryReader}); + * call {@link EntryReader#entry()} to keep one. Safe on a frozen map that several threads read. */ - private static Entry findInBucket(Object bucket, int hash, String tag) { - if (bucket instanceof Entry) { - Entry tagEntry = (Entry) bucket; - return tagEntry.matches(tag) ? tagEntry : null; - } else if (bucket instanceof BucketGroup) { - return ((BucketGroup) bucket).findInChain(hash, tag); + public EntryReader getEntryReader(String tag) { + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + return this.knownStore.getEntryReader(key); } - return null; + HashStore nameStore = this.nameStore; + return nameStore == null ? null : nameStore.get(tag); } - /** - * 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). - */ - 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; + /** {@link #getEntryReader(String)} for a known tag id. */ + public EntryReader getEntryReader(long tagId) { + return this.knownStore.getEntryReader(tagId); } @Deprecated @@ -1522,100 +1561,100 @@ public void set(@Nullable TagMap.EntryReader newEntryReader) { * from its id or a custom tag from its name. (Entry's own name factories are custom-only.) */ static Entry anyEntryFor(String tag, Object value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newAnyEntry(tagId, value) : Entry.newAnyEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newAnyEntry(key, value) : Entry.newAnyEntry(tag, value); } static Entry objectEntryFor(String tag, CharSequence value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newObjectEntry(tagId, value) : Entry.newObjectEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newObjectEntry(key, value) : Entry.newObjectEntry(tag, value); } static Entry booleanEntryFor(String tag, boolean value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newBooleanEntry(tagId, value) : Entry.newBooleanEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newBooleanEntry(key, value) : Entry.newBooleanEntry(tag, value); } static Entry intEntryFor(String tag, int value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newIntEntry(tagId, value) : Entry.newIntEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newIntEntry(key, value) : Entry.newIntEntry(tag, value); } static Entry longEntryFor(String tag, long value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newLongEntry(tagId, value) : Entry.newLongEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newLongEntry(key, value) : Entry.newLongEntry(tag, value); } static Entry floatEntryFor(String tag, float value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newFloatEntry(tagId, value) : Entry.newFloatEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newFloatEntry(key, value) : Entry.newFloatEntry(tag, value); } static Entry doubleEntryFor(String tag, double value) { - long tagId = KnownTagCodec.keyOf(tag); - return tagId != 0 ? Entry.newDoubleEntry(tagId, value) : Entry.newDoubleEntry(tag, value); + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? Entry.newDoubleEntry(key, value) : Entry.newDoubleEntry(tag, value); } public void set(@Nonnull String tag, @Nonnull Object value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newAnyEntry(key, value)); } else { - this.putEntry(Entry.newAnyEntry(tag, value)); + this.putCustom(Entry.newAnyEntry(tag, value)); } } public void set(@Nonnull String tag, @Nonnull CharSequence value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newObjectEntry(key, value)); } else { - this.putEntry(Entry.newObjectEntry(tag, value)); + this.putCustom(Entry.newObjectEntry(tag, value)); } } public void set(@Nonnull String tag, boolean value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newBooleanEntry(key, value)); } else { - this.putEntry(Entry.newBooleanEntry(tag, value)); + this.putCustom(Entry.newBooleanEntry(tag, value)); } } public void set(@Nonnull String tag, int value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newIntEntry(key, value)); } else { - this.putEntry(Entry.newIntEntry(tag, value)); + this.putCustom(Entry.newIntEntry(tag, value)); } } public void set(@Nonnull String tag, long value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newLongEntry(key, value)); } else { - this.putEntry(Entry.newLongEntry(tag, value)); + this.putCustom(Entry.newLongEntry(tag, value)); } } public void set(@Nonnull String tag, float value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newFloatEntry(key, value)); } else { - this.putEntry(Entry.newFloatEntry(tag, value)); + this.putCustom(Entry.newFloatEntry(tag, value)); } } public void set(@Nonnull String tag, double value) { - long tagId = KnownTagCodec.keyOf(tag); - if (tagId != 0) { - this.set(tagId, value); + long key = KnownTagCodec.keyOf(tag); + if (key != 0) { + this.putKnown(Entry.newDoubleEntry(key, value)); } else { - this.putEntry(Entry.newDoubleEntry(tag, value)); + this.putCustom(Entry.newDoubleEntry(tag, value)); } } @@ -1624,88 +1663,33 @@ public void set(@Nonnull String tag, double value) { * canonical name without the name lookup the String setters pay. The id must name a known tag; * custom tags have no id and use the String setters. */ - /** - * The tracer core's trusted operations on this map, which skip checks the core has already made. - * Restricted to the core; instrumentation uses the checked methods. - */ - @Restricted(allowedIn = {"datadog.trace.core"}) - public Internals internals() { - return new Internals(this); - } - - /** - * See {@link TagMap#internals()}. Created per call so escape analysis removes it: use it within - * the expression ({@code map.internals().setKnown(...)}) rather than storing it. - */ - @NoEscape - public static final class Internals { - private final TagMap map; - - Internals(TagMap map) { - this.map = map; - } - - /* - * Sets a known tag by id without validating it, unlike TagMap.set(long, ...): the caller has - * already checked KnownTagCodec.isKnown(tagId). - */ - public void setKnown(long tagId, @Nonnull Object value) { - this.map.putEntry(new Entry(tagId, null, Entry.ANY, 0L, value)); - } - - public void setKnown(long tagId, @Nonnull CharSequence value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.OBJECT, 0L, value)); - } - - public void setKnown(long tagId, boolean value) { - this.map.putEntry( - new Entry( - tagId, null, EntryReader.BOOLEAN, Entry.boolean2Prim(value), Boolean.valueOf(value))); - } - - public void setKnown(long tagId, int value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.INT, Entry.int2Prim(value), null)); - } - - public void setKnown(long tagId, long value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.LONG, Entry.long2Prim(value), null)); - } - - public void setKnown(long tagId, float value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.FLOAT, Entry.float2Prim(value), null)); - } - - public void setKnown(long tagId, double value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.DOUBLE, Entry.double2Prim(value), null)); - } - } public void set(long tagId, @Nonnull Object value) { - this.putEntry(Entry.newAnyEntry(tagId, value)); + this.putKnown(Entry.newAnyEntry(tagId, value)); } public void set(long tagId, @Nonnull CharSequence value) { - this.putEntry(Entry.newObjectEntry(tagId, value)); + this.putKnown(Entry.newObjectEntry(tagId, value)); } public void set(long tagId, boolean value) { - this.putEntry(Entry.newBooleanEntry(tagId, value)); + this.putKnown(Entry.newBooleanEntry(tagId, value)); } public void set(long tagId, int value) { - this.putEntry(Entry.newIntEntry(tagId, value)); + this.putKnown(Entry.newIntEntry(tagId, value)); } public void set(long tagId, long value) { - this.putEntry(Entry.newLongEntry(tagId, value)); + this.putKnown(Entry.newLongEntry(tagId, value)); } public void set(long tagId, float value) { - this.putEntry(Entry.newFloatEntry(tagId, value)); + this.putKnown(Entry.newFloatEntry(tagId, value)); } public void set(long tagId, double value) { - this.putEntry(Entry.newDoubleEntry(tagId, value)); + this.putKnown(Entry.newDoubleEntry(tagId, value)); } /** @@ -1718,22 +1702,11 @@ 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); + this.checkWriteAccess(); - 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; - } - // By the entry's own identity, not its name: the name would only resolve back to the same hash. - return this.parent.getEntry(newEntry.tag, newEntry.tagHash); + return newEntry.tagId() != 0 + ? entryOf(this.knownStore.getAndPut(newEntry)) + : this.nameStoreForWrite().getAndPut(newEntry); } /** @@ -1745,55 +1718,23 @@ public Entry getAndSet(@Nullable Entry newEntry) { 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(); - int bucketIndex = newHash & (thisBuckets.length - 1); - - Object bucket = thisBuckets[bucketIndex]; - if (bucket == null) { - thisBuckets[bucketIndex] = newEntry; - - this.size += 1; - return null; - } else if (bucket instanceof Entry) { - Entry existingEntry = (Entry) bucket; - if (existingEntry.matches(newEntry.tag)) { - thisBuckets[bucketIndex] = newEntry; - - // replaced existing entry - no size change - return existingEntry; - } else { - thisBuckets[bucketIndex] = - new BucketGroup(existingEntry.hash(), existingEntry, newHash, newEntry); + return newEntry.tagId() != 0 + ? entryOf(this.knownStore.put(newEntry)) + : this.nameStoreForWrite().put(newEntry); + } - this.size += 1; - return null; - } - } else if (bucket instanceof BucketGroup) { - BucketGroup lastGroup = (BucketGroup) bucket; + /** {@link #putEntry} for an entry known to be a known tag's. */ + void putKnown(Entry knownEntry) { + this.checkWriteAccess(); - BucketGroup containingGroup = lastGroup.findContainingGroupInChain(newHash, newEntry.tag); - if (containingGroup != null) { - // replaced existing entry - no size change - return containingGroup._replace(newHash, newEntry); - } + this.knownStore.put(knownEntry); + } - if (!lastGroup.insertInChain(newHash, newEntry)) { - thisBuckets[bucketIndex] = new BucketGroup(newHash, newEntry, lastGroup); - } - this.size += 1; - return null; - } + /** {@link #putEntry} for an entry known to be a custom tag's. */ + void putCustom(Entry customEntry) { + this.checkWriteAccess(); - // unreachable - return null; + this.nameStoreForWrite().put(customEntry); } public Entry getAndSet(@Nonnull String tag, Object value) { @@ -1855,209 +1796,44 @@ 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). + if (that.readsThrough()) { + // read-through source: the bucket-copy paths 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 { - this.putAllMerge(that); - } - } - - private void putAllMerge(TagMap that) { - Object[] thisBuckets = this.buckets; - Object[] thatBuckets = that.buckets; - - // Since TagMap-s don't support expansion, 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) { - Object thatBucket = thatBuckets[i]; - - // if nothing incoming, nothing to do - if (thatBucket == null) continue; - - Object thisBucket = thisBuckets[i]; - if (thisBucket == null) { - // This bucket is null, easy case - // Either copy over the sole entry or clone the BucketGroup chain - - if (thatBucket instanceof Entry) { - thisBuckets[i] = thatBucket; - this.size += 1; - } else if (thatBucket instanceof BucketGroup) { - BucketGroup thatGroup = (BucketGroup) thatBucket; - - BucketGroup thisNewGroup = thatGroup.cloneChain(); - thisBuckets[i] = thisNewGroup; - this.size += thisNewGroup.sizeInChain(); - } - } else if (thisBucket instanceof Entry) { - // This bucket is a single entry, medium complexity case - // If other side is an Entry - just merge the entries into a bucket - // If other side is a BucketGroup - then clone the group and insert the entry normally into - // the cloned group - - Entry thisEntry = (Entry) thisBucket; - int thisHash = thisEntry.hash(); - - if (thatBucket instanceof Entry) { - Entry thatEntry = (Entry) thatBucket; - int thatHash = thatEntry.hash(); - - if (thisHash == thatHash && thisEntry.matches(thatEntry.tag())) { - thisBuckets[i] = thatEntry; - // replacing entry, no size change - } else { - thisBuckets[i] = - new BucketGroup( - thisHash, thisEntry, - thatHash, thatEntry); - this.size += 1; - } - } else if (thatBucket instanceof BucketGroup) { - BucketGroup thatGroup = (BucketGroup) thatBucket; - - // Clone the other group, then place this entry into that group - BucketGroup thisNewGroup = thatGroup.cloneChain(); - int thisNewGroupSize = thisNewGroup.sizeInChain(); - - 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 - thisBuckets[i] = thisNewGroup; - - // overlapping group - subtract one for clobbered existing entry - this.size += thisNewGroupSize - 1; - } else if (thisNewGroup.insertInChain(thisHash, thisEntry)) { - // able to add thisEntry into the existing groups - thisBuckets[i] = thisNewGroup; - - // non overlapping group - existing entry already accounted for in this.size - this.size += 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 - this.size += thisNewGroupSize; - } - } - } else if (thisBucket instanceof BucketGroup) { - // This bucket is a BucketGroup, medium to hard case - // If the other side is an entry, just normal insertion procedure - no cloning required - BucketGroup thisGroup = (BucketGroup) thisBucket; - - if (thatBucket instanceof Entry) { - Entry thatEntry = (Entry) thatBucket; - int thatHash = thatEntry.hash(); - - if (thisGroup.replaceInChain(thatHash, thatEntry) != null) { - // replaced existing entry no size change - } else if (thisGroup.insertInChain(thatHash, thatEntry)) { - this.size += 1; - } else { - thisBuckets[i] = new BucketGroup(thatHash, thatEntry, thisGroup); - this.size += 1; - } - } else if (thatBucket instanceof BucketGroup) { - // Most complicated case - need to walk that bucket group chain and update this chain - BucketGroup thatGroup = (BucketGroup) thatBucket; - - // Taking the easy / expensive way out for updating size - int thisPrevGroupSize = thisGroup.sizeInChain(); - - BucketGroup thisNewGroup = thisGroup.replaceOrInsertAllInChain(thatGroup); - int thisNewGroupSize = thisNewGroup.sizeInChain(); - - thisBuckets[i] = thisNewGroup; - this.size += (thisNewGroupSize - thisPrevGroupSize); - } - } + this.knownStore.putAllLocal(that.knownStore); + HashStore thatNames = that.nameStore; + if (thatNames != null && thatNames.count != 0) { + this.nameStoreForWrite().putAllLocal(thatNames); } } - /* - * Specially optimized version of putAll for the common case of destination map being empty - */ - private void putAllIntoEmptyMap(TagMap that) { - Object[] thisBuckets = this.buckets; - Object[] thatBuckets = that.buckets; - - // 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) { - Object thatBucket = thatBuckets[i]; - - // faster to explicitly null check first, then do instanceof - if (thatBucket == null) { - // do nothing - } else if (thatBucket instanceof BucketGroup) { - // if it is a BucketGroup, then need to clone - BucketGroup thatGroup = (BucketGroup) thatBucket; - - thisBuckets[i] = thatGroup.cloneChain(); - } else { // if ( thatBucket instanceof Entry ) - thisBuckets[i] = thatBucket; - } - } - this.size = that.size; - } - public void fillMap(Map map) { - if (this.parent != null) { + if (this.readsThrough()) { // read-through source: the bucket-only loop below sees just local entries, so it would drop // tags visible only through the parent chain (and ignore shadowing/tombstones). Walk the full // visible union instead -- mirrors putAllOptimizedMap. this.forEach(map, (m, entry) -> m.put(entry.tag(), entry.objectValue())); return; } - - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - 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); - } + this.knownStore.fillMapLocal(map); + if (this.nameStore != null) { + this.nameStore.fillMapLocal(map); } } public void fillStringMap(Map stringMap) { - if (this.parent != null) { + if (this.readsThrough()) { // read-through source: walk the visible union so parent-only tags aren't dropped -- see // fillMap. this.forEach(stringMap, (m, entry) -> m.put(entry.tag(), entry.stringValue())); return; } - - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - 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); - } + this.knownStore.fillStringMapLocal(stringMap); + if (this.nameStore != null) { + this.nameStore.fillStringMapLocal(stringMap); } } @@ -2077,94 +1853,29 @@ public Entry getAndRemove(String tag) { this.checkWriteAccess(); // See getEntry: entries are stored under their canonical Datadog name, so a removal by an - // OpenTelemetry rename must canonicalize first to find (and tombstone) the right entry. - long tagHash = Entry.tagHashOf(tag); - String canonicalTag = (tagHash >>> 32) != 0 ? KnownTagCodec.nameOf(tagHash) : tag; - return this.getAndRemove(canonicalTag, tagHash); + // OpenTelemetry rename must resolve to the id first to find (and tombstone) the right entry. + long key = KnownTagCodec.keyOf(tag); + return key != 0 ? entryOf(this.knownStore.remove(key)) : this.removeCustom(tag); } /** Removes the entry for a known tag id; see {@link #getEntry(long)}. */ public Entry getAndRemove(long tagId) { this.checkWriteAccess(); - return this.getAndRemove(Entry.requireKnownName(tagId), tagId); - } - - private Entry getAndRemove(String canonicalTag, long tagHash) { - Entry localRemoved = this.removeLocal(canonicalTag, tagHash); - - 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(canonicalTag); - if (!alreadyTombstoned) { - Entry parentEntry = parent.getEntry(canonicalTag, tagHash); - 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(canonicalTag); - 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, long tagHash) { - Object[] thisBuckets = this.buckets; - - int hash = Entry.bucketHash(tagHash); - 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)) { - thisBuckets[bucketIndex] = null; - - this.size -= 1; - return existingEntry; - } else { - return null; - } - } else if (bucket instanceof BucketGroup) { - BucketGroup lastGroup = (BucketGroup) bucket; - - BucketGroup containingGroup = lastGroup.findContainingGroupInChain(hash, tag); - if (containingGroup == null) { - return null; - } - - Entry existingEntry = containingGroup._remove(hash, tag); - if (containingGroup._isEmpty()) { - this.buckets[bucketIndex] = lastGroup.removeGroupInChain(containingGroup); - } - - this.size -= 1; - return existingEntry; - } - return null; + return entryOf(this.knownStore.remove(tagId)); } public TagMap copy() { - // 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; + // The same (frozen, shared) parent, fixed at construction, over a copy of this map's own + // entries and tombstones. The copy stays independently mutable (writes land on its local + // store, never the shared parent). + HashStore nameStore = this.nameStore; + boolean borrowed = this.nameStoreBorrowed; + return new TagMap( + this.parent, + this.knownStore.copy(), + nameStore == null || borrowed ? nameStore : nameStore.copy(), + borrowed); } public TagMap immutableCopy() { @@ -2186,161 +1897,40 @@ public Stream stream() { @Override public void forEach(Consumer consumer) { - Object[] thisBuckets = this.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); - } - } - - // 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); - } - } - } - } + this.knownStore.forEach(consumer); + HashStore nameStore = this.nameStore; + if (nameStore != null) { + nameStore.forEach(consumer); } } public void forEach(T thisObj, BiConsumer consumer) { - Object[] thisBuckets = this.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); - } - } - - // 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); - } - } - } - } - } + this.knownStore.forEach(thisObj, consumer); + HashStore nameStore = this.nameStore; + if (nameStore != null) { + nameStore.forEach(thisObj, consumer); } } public void forEach( T thisObj, U otherObj, TriConsumer consumer) { - Object[] thisBuckets = this.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); - } - } - - // 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); - } - } - } - } - } + this.knownStore.forEach(thisObj, otherObj, consumer); + HashStore nameStore = this.nameStore; + if (nameStore != null) { + nameStore.forEach(thisObj, otherObj, consumer); } } public void clear() { this.checkWriteAccess(); - 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 + // clear() removes ALL mappings, including any inherited through read-through. Fresh parentless + // stores (rather than tombstoning every inherited key) are simpler and cheaper, and leave an // empty, parent-less map. Detach is one-way -- the parent is never re-pointed. + this.knownStore = IdStoreFactory.Default.INSTANCE.create(null); + this.nameStore = null; + this.nameStoreBorrowed = false; this.parent = null; - this.removedFromParent = null; } public TagMap freeze() { @@ -2358,85 +1948,21 @@ public void checkWriteAccess() { } void checkIntegrity() { - // 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 = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - int thisHash = thisEntry.hash(); - - int expectedBucket = thisHash & (thisBuckets.length - 1); - if (expectedBucket != i) { - throw new IllegalStateException("incorrect bucket"); - } - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - for (BucketGroup curGroup = thisGroup; curGroup != null; curGroup = curGroup.prev) { - for (int j = 0; j < BucketGroup.LEN; ++j) { - Entry thisEntry = curGroup._entryAt(i); - if (thisEntry == null) continue; - - int thisHash = thisEntry.hash(); - assert curGroup._hashAt(i) == thisHash; - - int expectedBucket = thisHash & (thisBuckets.length - 1); - if (expectedBucket != i) { - throw new IllegalStateException("incorrect bucket"); - } - } - } - } - } - - if (this.size != this.computeSize()) { - throw new IllegalStateException("incorrect size"); - } - // 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"); + this.knownStore.checkIntegrity(); + if (this.nameStore != null && !this.nameStoreBorrowed) { + this.nameStore.checkIntegrity(); } } + /** The local entries, recomputed from both stores' structure. */ int computeSize() { - Object[] thisBuckets = this.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; + HashStore nameStore = this.nameStore; + return this.knownStore.computeSize() + + (nameStore == null || this.nameStoreBorrowed ? 0 : nameStore.computeSize()); } boolean checkIfEmpty() { - Object[] thisBuckets = this.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; + return this.computeSize() == 0; } @Override @@ -2474,19 +2000,19 @@ public String toString() { String toPrettyString() { boolean first = true; - StringBuilder ledger = new StringBuilder(128); - ledger.append('{'); + StringBuilder builder = new StringBuilder(128); + builder.append('{'); for (EntryReader entry : this) { if (first) { first = false; } else { - ledger.append(", "); + builder.append(", "); } - ledger.append(entry.tag()).append('=').append(entry.stringValue()); + builder.append(entry.tag()).append('=').append(entry.stringValue()); } - ledger.append('}'); - return ledger.toString(); + builder.append('}'); + return builder.toString(); } /** @@ -2494,48 +2020,26 @@ String toPrettyString() { * debugging */ String toInternalString() { - Object[] thisBuckets = this.buckets; - - StringBuilder ledger = new StringBuilder(128); - for (int i = 0; i < thisBuckets.length; ++i) { - ledger.append('[').append(i).append("] = "); - - Object thisBucket = thisBuckets[i]; - if (thisBucket == null) { - ledger.append("null"); - } else if (thisBucket instanceof Entry) { - ledger.append('{').append(thisBucket).append('}'); - } else if (thisBucket instanceof BucketGroup) { - for (BucketGroup curGroup = (BucketGroup) thisBucket; - curGroup != null; - curGroup = curGroup.prev) { - ledger.append(curGroup).append(" -> "); - } - } - ledger.append('\n'); - } - return ledger.toString(); + HashStore nameStore = this.nameStore; + return "knownStore:\n" + + this.knownStore.toInternalString() + + "nameStore:\n" + + (nameStore == null ? "none\n" : nameStore.toInternalString()); } abstract static class IteratorBase { - private final TagMap map; + // Two phases: the id store's visible entries, through its own cursor, then the custom store's + // chain -- the leaf's buckets first, then each ancestor's in turn (read-through union). + private IdStore.Cursor knownCursor; // null once the id phase is done + private final HashStore nameLeaf; + private HashStore nameLevel; // the custom level being walked; null before and after + private HashSupport.BucketWalker walker; - // 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; - - private int bucketIndex = -1; - - private BucketGroup group = null; - private int groupIndex = 0; + private EntryReader nextEntry; IteratorBase(TagMap map) { - this.map = map; - this.level = map; - this.buckets = map.buckets; + this.knownCursor = map.knownStore.cursor(); + this.nameLeaf = map.nameStore; } public final boolean hasNext() { @@ -2545,9 +2049,9 @@ public final boolean hasNext() { return this.nextEntry != null; } - final Entry nextEntryOrThrowNoSuchElement() { + final EntryReader nextEntryOrThrowNoSuchElement() { if (this.nextEntry != null) { - Entry nextEntry = this.nextEntry; + EntryReader nextEntry = this.nextEntry; this.nextEntry = null; return nextEntry; } @@ -2559,72 +2063,38 @@ final Entry nextEntryOrThrowNoSuchElement() { } } - final Entry nextEntryOrNull() { - if (this.nextEntry != null) { - Entry nextEntry = this.nextEntry; - this.nextEntry = null; - return nextEntry; - } - - return this.hasNext() ? this.nextEntry : null; - } - - 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 + private EntryReader advance() { + IdStore.Cursor knownCursor = this.knownCursor; + if (knownCursor != null) { + EntryReader reader = knownCursor.next(); + if (reader != null) { + return reader; } - - // 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; + this.knownCursor = null; + if (this.nameLeaf == null) { + return null; } - 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) { - Entry tagEntry = this.group._entryAt(this.groupIndex); - if (tagEntry != null) return tagEntry; + this.nameLevel = this.nameLeaf; + this.walker = new HashSupport.BucketWalker(); + this.walker.reset(this.nameLeaf.buckets); + } + + HashStore level = this.nameLevel; + while (level != null) { + Entry entry = this.walker.next(); + if (entry != null) { + // leaf entries emit as-is; ancestor entries only if not shadowed by a nearer level + if (level == this.nameLeaf || this.nameLeaf.parentEntryVisible(entry, level)) { + return entry; } - - // done processing - that group, go to next group - this.group = this.group.prev; - this.groupIndex = -1; + continue; } - - // 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; - } - } + level = level.parent; + this.nameLevel = level; + if (level != null) { + this.walker.reset(level.buckets); } } - return null; } } @@ -2799,6 +2269,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; @@ -2988,86 +2483,6 @@ Entry _remove(int hash, String tag) { return existingEntry; } - void forEachInChain(Consumer consumer) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._forEach(consumer); - } - } - - void _forEach(Consumer 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 map) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._fillMap(map); - } - } - - void _fillMap(Map 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 map) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._fillStringMap(map); - } - } - - void _fillStringMap(Map 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(); @@ -3092,15 +2507,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(); } } @@ -3123,9 +2538,23 @@ public boolean isEmpty() { @Override public Iterator> iterator() { - @SuppressWarnings({"rawtypes", "unchecked"}) - Iterator> iter = (Iterator) this.map.iterator(); - return iter; + return new EntriesIterator(this.map); + } + } + + /** + * Map entries for the {@link Map} view: an Entry is its own, while a flyweight reader (a dense + * store's) builds one, since a caller may keep it. + */ + static final class EntriesIterator extends IteratorBase + implements Iterator> { + EntriesIterator(TagMap map) { + super(map); + } + + @Override + public Map.Entry next() { + return this.nextEntryOrThrowNoSuchElement().mapEntry(); } } @@ -3208,113 +2637,6 @@ public Object next() { } } -final class EntryReadingHelper implements TagMap.EntryReader { - private Map.Entry mapEntry; - private String tag; - private Object value; - private long tagId; - - void set(String tag, Object value) { - this.mapEntry = null; - this.tag = tag; - this.value = value; - this.tagId = TagMap.TAG_ID_NOT_COMPUTED; // resolve lazily via keyOf on first tagId() access - } - - void set(Map.Entry mapEntry) { - this.mapEntry = mapEntry; - this.tag = mapEntry.getKey(); - this.value = mapEntry.getValue(); - this.tagId = TagMap.TAG_ID_NOT_COMPUTED; // resolve lazily via keyOf on first tagId() access - } - - @Override - public String tag() { - return this.tag; - } - - @Override - public long tagId() { - long id = this.tagId; - if (id != TagMap.TAG_ID_NOT_COMPUTED) return id; - - id = KnownTagCodec.keyOf(this.tag); - this.tagId = id; - return id; - } - - @Override - public byte type() { - return TagValueConversions.typeOf(this.value); - } - - @Override - public boolean is(byte type) { - return TagValueConversions.isA(this.value, type); - } - - @Override - public boolean isNumber() { - return TagValueConversions.isNumber(this.value); - } - - @Override - public boolean isNumericPrimitive() { - return TagValueConversions.isNumericPrimitive(this.value); - } - - @Override - public boolean isObject() { - return TagValueConversions.isObject(this.value); - } - - @Override - public boolean booleanValue() { - return TagValueConversions.toBoolean(this.value); - } - - @Override - public int intValue() { - return TagValueConversions.toInt(this.value); - } - - @Override - public long longValue() { - return TagValueConversions.toLong(this.value); - } - - @Override - public float floatValue() { - return TagValueConversions.toFloat(this.value); - } - - @Override - public double doubleValue() { - return TagValueConversions.toDouble(this.value); - } - - @Override - public String stringValue() { - return TagValueConversions.toString(this.value); - } - - @Override - public Object objectValue() { - return this.value; - } - - @Override - public TagMap.Entry entry() { - return TagMap.anyEntryFor(this.tag, this.value); - } - - @Override - public Map.Entry mapEntry() { - Map.Entry mapEntry = this.mapEntry; - return (mapEntry != null) ? mapEntry : this.entry(); - } -} - final class TagValueConversions { TagValueConversions() {} diff --git a/internal-api/src/test/java/datadog/trace/api/DenseIdStoreConcurrentReadTest.java b/internal-api/src/test/java/datadog/trace/api/DenseIdStoreConcurrentReadTest.java new file mode 100644 index 00000000000..c076bf34725 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/DenseIdStoreConcurrentReadTest.java @@ -0,0 +1,73 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Point reads of a frozen dense store that several threads share -- a span prototype's tags, the + * tracer-level tags -- must not share state: each read builds its own reader or Entry. + */ +class DenseIdStoreConcurrentReadTest { + + @Test + void concurrentPointReadsOfASharedStoreKeepTheirOwnValues() throws Exception { + List ids = new ArrayList<>(); + for (java.lang.reflect.Field field : KnownTags.class.getFields()) { + if (field.getType() == long.class && ids.size() < 12) { + long id = field.getLong(null); + if (KnownTagCodec.isKnown(id) && !ids.contains(id)) { + ids.add(id); + } + } + } + DenseIdStore store = new DenseIdStore(null); + for (long id : ids) { + store.set(id, KnownTagCodec.nameOf(id)); // each value names its own tag + } + TagMap shared = TagMap.viewOf(store, null, false); + + int threads = 8; + CountDownLatch start = new CountDownLatch(1); + AtomicReference mismatch = new AtomicReference<>(); + List readers = new ArrayList<>(); + for (int t = 0; t < threads; ++t) { + final int offset = t; + Thread reader = + new Thread( + () -> { + try { + start.await(); + } catch (InterruptedException e) { + return; + } + for (int i = 0; i < 20_000 && mismatch.get() == null; ++i) { + long id = ids.get((i + offset) % ids.size()); + String name = KnownTagCodec.nameOf(id); + TagMap.Entry entry = shared.getEntry(id); + TagMap.EntryReader viaReader = store.getEntryReader(id); + if (!name.equals(entry.tag()) || !name.equals(entry.objectValue())) { + mismatch.set(name + " read as " + entry.tag() + "=" + entry.objectValue()); + } else if (!name.equals(viaReader.tag()) + || !name.equals(viaReader.objectValue())) { + mismatch.set( + name + " read as " + viaReader.tag() + "=" + viaReader.objectValue()); + } + } + }); + readers.add(reader); + reader.start(); + } + start.countDown(); + for (Thread reader : readers) { + reader.join(30_000); + assertTrue(!reader.isAlive(), "reader finished"); + } + assertEquals(null, mismatch.get()); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/DenseIdStoreForkedTest.java b/internal-api/src/test/java/datadog/trace/api/DenseIdStoreForkedTest.java new file mode 100644 index 00000000000..a2f0dd37a3f --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/DenseIdStoreForkedTest.java @@ -0,0 +1,50 @@ +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.assertSame; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * The dense-store flag on: only {@link DenseIdStore} is loaded, so calls on an {@link IdStore} stay + * monomorphic. The flag is read once into a static final, so this runs in its own JVM ({@code + * ForkedTest}) with the flag set before anything reads it. The shared suites cover the dense + * store's behavior when run with {@code DD_TRACE_EXPERIMENTAL_DENSE_TAGS_ENABLED=true}. + */ +class DenseIdStoreForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + @Test + void onlyTheDenseStoreIsLoaded() { + TagMap map = TagMap.create(); + map.set(KnownTags.COMPONENT_ID, "okhttp"); + map.set("custom.tag", "custom"); + + assertSame(DenseIdStore.class, map.knownStore.getClass()); + assertSame(DenseIdStore.class, TagMap.EMPTY.knownStore.getClass()); + assertEquals("okhttp", map.getObject(KnownTags.COMPONENT_NAME)); + assertFalse(isLoaded("datadog.trace.api.HashIdStore")); + } + + /** Whether {@code className} has been loaded by TagMap's class loader, without loading it. */ + private static boolean isLoaded(String className) { + Method findLoadedClass; + try { + findLoadedClass = ClassLoader.class.getDeclaredMethod("findLoadedClass", String.class); + findLoadedClass.setAccessible(true); + } catch (RuntimeException | NoSuchMethodException e) { + assumeTrue(false, "cannot inspect loaded classes on this JVM: " + e); + return false; + } + try { + return findLoadedClass.invoke(TagMap.class.getClassLoader(), className) != null; + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/DenseReaderTest.java b/internal-api/src/test/java/datadog/trace/api/DenseReaderTest.java new file mode 100644 index 00000000000..ae9b0f4b31d --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/DenseReaderTest.java @@ -0,0 +1,107 @@ +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.assertTrue; + +import datadog.trace.api.DenseIdStore.DenseReader; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DenseReaderTest { + + @Test + void exposesAnIntValue() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + + assertEquals(200, reader.intValue()); + assertEquals(200L, reader.longValue()); + assertEquals(TagMap.EntryReader.INT, reader.type()); + assertTrue(reader.isNumber()); + assertTrue(reader.isNumericPrimitive()); + assertFalse(reader.isObject()); + assertTrue(reader.is(TagMap.EntryReader.INT)); + } + + @Test + void exposesALongValue() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_STATUS_CODE_ID, 123456789L); + + assertEquals(123456789L, reader.longValue()); + assertEquals(TagMap.EntryReader.LONG, reader.type()); + assertTrue(reader.isNumericPrimitive()); + } + + @Test + void exposesABooleanValue() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_STATUS_CODE_ID, true); + + assertTrue(reader.booleanValue()); + assertEquals(TagMap.EntryReader.BOOLEAN, reader.type()); + assertFalse(reader.isNumber()); + assertFalse(reader.isObject()); + } + + @Test + void exposesFloatAndDoubleValues() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_STATUS_CODE_ID, 1.5f); + assertEquals(1.5f, reader.floatValue()); + assertEquals(TagMap.EntryReader.FLOAT, reader.type()); + + reader.set(KnownTags.HTTP_STATUS_CODE_ID, 3.14); + assertEquals(3.14, reader.doubleValue()); + assertEquals(TagMap.EntryReader.DOUBLE, reader.type()); + } + + @Test + void exposesAStringAsAnObject() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_METHOD_ID, "GET"); + + assertEquals("GET", reader.stringValue()); + assertEquals("GET", reader.objectValue()); + assertEquals(TagMap.EntryReader.OBJECT, reader.type()); + assertTrue(reader.isObject()); + assertFalse(reader.isNumber()); + } + + @Test + void namesTheTagFromItsId() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_METHOD_ID, "GET"); + + assertEquals(KnownTags.HTTP_METHOD_ID, reader.tagId()); + assertEquals(KnownTags.HTTP_METHOD_NAME, reader.tag()); + assertEquals(KnownTags.HTTP_METHOD_NAME, reader.tag()); // the resolved name is reused + } + + @Test + void aNewTagReplacesTheResolvedName() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_METHOD_ID, "GET"); + assertEquals(KnownTags.HTTP_METHOD_NAME, reader.tag()); + + reader.set(KnownTags.HTTP_ROUTE_ID, "/users"); + assertEquals(KnownTags.HTTP_ROUTE_ID, reader.tagId()); + assertEquals(KnownTags.HTTP_ROUTE_NAME, reader.tag()); + } + + @Test + void buildsAnEntryForTheTag() { + DenseReader reader = new DenseReader(); + reader.set(KnownTags.HTTP_METHOD_ID, "GET"); + + TagMap.Entry entry = reader.entry(); + assertEquals(KnownTags.HTTP_METHOD_ID, entry.tagId()); + assertEquals(KnownTags.HTTP_METHOD_NAME, entry.tag()); + assertEquals("GET", entry.objectValue()); + + Map.Entry mapEntry = reader.mapEntry(); + assertEquals(KnownTags.HTTP_METHOD_NAME, mapEntry.getKey()); + assertEquals("GET", mapEntry.getValue()); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java b/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java deleted file mode 100644 index 5da8564cb87..00000000000 --- a/internal-api/src/test/java/datadog/trace/api/EntryReadingHelperTest.java +++ /dev/null @@ -1,169 +0,0 @@ -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.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.AbstractMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class EntryReadingHelperTest { - - @Test - void setTagValueExposesIntValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("my.tag", 42); - - assertEquals("my.tag", helper.tag()); - assertEquals(42, helper.intValue()); - assertEquals(42L, helper.longValue()); - assertEquals(TagMap.EntryReader.INT, helper.type()); - assertTrue(helper.isNumber()); - assertTrue(helper.isNumericPrimitive()); - assertFalse(helper.isObject()); - assertTrue(helper.is(TagMap.EntryReader.INT)); - } - - @Test - void setTagValueExposesLongValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("id", 123456789L); - - assertEquals(123456789L, helper.longValue()); - assertEquals(TagMap.EntryReader.LONG, helper.type()); - assertTrue(helper.isNumber()); - assertTrue(helper.isNumericPrimitive()); - } - - @Test - void setTagValueExposesBooleanValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("flag", true); - - assertTrue(helper.booleanValue()); - assertEquals(TagMap.EntryReader.BOOLEAN, helper.type()); - assertFalse(helper.isNumber()); - assertFalse(helper.isObject()); - } - - @Test - void setTagValueExposesFloatValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("rate", 1.5f); - - assertEquals(1.5f, helper.floatValue()); - assertEquals(TagMap.EntryReader.FLOAT, helper.type()); - assertTrue(helper.isNumericPrimitive()); - } - - @Test - void setTagValueExposesDoubleValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("pi", 3.14); - - assertEquals(3.14, helper.doubleValue()); - assertEquals(TagMap.EntryReader.DOUBLE, helper.type()); - assertTrue(helper.isNumericPrimitive()); - } - - @Test - void setTagValueExposesStringAsObject() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("name", "foo"); - - assertEquals("foo", helper.stringValue()); - assertEquals("foo", helper.objectValue()); - assertEquals(TagMap.EntryReader.OBJECT, helper.type()); - assertTrue(helper.isObject()); - assertFalse(helper.isNumber()); - assertFalse(helper.isNumericPrimitive()); - } - - @Test - void setTagValueCreatesEntry() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("tag", "value"); - - TagMap.Entry entry = helper.entry(); - assertNotNull(entry); - assertEquals("tag", entry.tag()); - assertEquals("value", entry.objectValue()); - } - - @Test - void mapEntryFallsBackToNewEntryWhenSetWithTagValue() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("tag", "value"); - - Map.Entry mapEntry = helper.mapEntry(); - assertEquals("tag", mapEntry.getKey()); - assertEquals("value", mapEntry.getValue()); - } - - @Test - void setMapEntryExposesReadMethods() { - EntryReadingHelper helper = new EntryReadingHelper(); - Map.Entry original = new AbstractMap.SimpleEntry<>("key", "hello"); - helper.set(original); - - assertEquals("key", helper.tag()); - assertEquals("hello", helper.stringValue()); - assertEquals("hello", helper.objectValue()); - assertEquals(TagMap.EntryReader.OBJECT, helper.type()); - assertTrue(helper.isObject()); - assertFalse(helper.isNumber()); - } - - @Test - void mapEntryReturnsSameInstanceWhenSetWithMapEntry() { - EntryReadingHelper helper = new EntryReadingHelper(); - Map.Entry original = new AbstractMap.SimpleEntry<>("key", "value"); - helper.set(original); - - assertSame(original, helper.mapEntry()); - } - - @Test - void setMapEntryCreatesEntry() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set(new AbstractMap.SimpleEntry<>("k", 99)); - - TagMap.Entry entry = helper.entry(); - assertNotNull(entry); - assertEquals("k", entry.tag()); - assertEquals(99, entry.objectValue()); - } - - @Test - void tagIdResolvesLazilyAndCaches() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("http.method", "GET"); - - long expectedId = KnownTagCodec.keyOf("http.method"); - assertEquals(expectedId, helper.tagId()); - // second call hits the already-resolved cache rather than re-resolving - assertEquals(expectedId, helper.tagId()); - } - - @Test - void tagIdOfUnknownTagResolvesToZeroAndCaches() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("my.custom.tag", "value"); - - assertEquals(0L, helper.tagId()); - assertEquals(0L, helper.tagId()); - } - - @Test - void tagIdIsRecomputedAfterSet() { - EntryReadingHelper helper = new EntryReadingHelper(); - helper.set("http.method", "GET"); - assertEquals(KnownTagCodec.keyOf("http.method"), helper.tagId()); - - helper.set(new AbstractMap.SimpleEntry<>("db.type", "mysql")); - assertEquals(KnownTagCodec.keyOf("db.type"), helper.tagId()); - } -} 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..64c7f606b48 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java @@ -0,0 +1,577 @@ +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)), + arguments(named("DenseIdStore", (Function) DenseIdStore::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 aViewReadsValuesFromEitherStore(Function newStore) { + IdStore store = newStore.apply(null); + store.set(KnownTags.HTTP_ROUTE_ID, "/a"); + store.set(KnownTags.PEER_PORT_ID, 5432); + HashStore names = HashStore.create(null); + names.set("my.tag", 7); + + TagMap view = TagMap.viewOf(store, names, false); + assertEquals("/a", view.getObject(KnownTags.HTTP_ROUTE_NAME)); + assertEquals(5432, view.getObject("peer.port")); + assertEquals("5432", view.getString("peer.port")); + assertEquals(7, view.getObject("my.tag")); + assertEquals("7", view.getString("my.tag")); + assertNull(view.getObject(KnownTags.HTTP_METHOD_NAME)); + assertNull(view.getString("other.tag")); + assertNull(TagMap.viewOf(store, null, false).getString("my.tag"), "no custom store"); + } + + @ParameterizedTest + @MethodSource("stores") + void aViewReadsPrimitivesThroughAReader(Function newStore) { + IdStore store = newStore.apply(null); + store.set(KnownTags.PEER_PORT_ID, 5432); + store.set(KnownTags.DB_INSTANCE_ID, 1.5d); + store.set(KnownTags.DD_MEASURED_ID, true); + HashStore names = HashStore.create(null); + names.set("my.count", 7L); + + TagMap view = TagMap.viewOf(store, names, false); + assertEquals(5432, view.getInt("peer.port")); + assertEquals(5432L, view.getLong("peer.port")); + assertEquals(1.5d, view.getDouble("db.instance")); + assertEquals(1.5f, view.getFloat("db.instance")); + assertTrue(view.getBoolean(KnownTags.DD_MEASURED_NAME)); + assertEquals(7L, view.getLong("my.count")); + assertEquals(-1, view.getIntOrDefault(KnownTags.HTTP_METHOD_NAME, -1)); + assertEquals(-1L, view.getLongOrDefault("other.tag", -1L)); + assertTrue(view.containsKey("peer.port")); + assertTrue(view.containsKey("my.count")); + assertFalse(view.containsKey("other.tag")); + + EntryReader port = view.getEntryReader(KnownTags.PEER_PORT_ID); + assertEquals(5432, port.intValue()); + assertEquals(KnownTags.PEER_PORT_ID, port.tagId()); + assertEquals(7L, view.getEntryReader("my.count").longValue()); + assertNull(view.getEntryReader(KnownTags.HTTP_METHOD_ID)); + assertNull(TagMap.viewOf(store, null, false).getEntryReader("my.count"), "no custom store"); + } + + @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/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java b/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java index ded02b397e5..34c240757c8 100644 --- a/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java @@ -81,4 +81,14 @@ void anUnslottedTagSharesTheReservedTopBit() { assertEquals(1L << 63, 1L << KnownTagCodec.slotOf(KnownTags.RESOURCE_NAME_ID)); assertTrue(KnownTagCodec.slotCount() <= 63, "real slots must stay below the shared bit 63"); } + + @Test + void makeTagIdBuildsTheRealIdOfAKnownTag() { + // a slotted, intercepted tag: its constant carries slot and flag bits beyond the serial + long id = KnownTags.HTTP_STATUS_CODE_ID; + assertEquals(id, KnownTagCodec.makeTagId(KnownTagCodec.serialNum(id))); + + long unknown = KnownTagCodec.makeTagId(KnownTagCodec.serialLimit() + 1); + assertFalse(KnownTagCodec.isKnown(unknown)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java index 29c7fa1ac17..11258ba3722 100644 --- a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java @@ -285,6 +285,21 @@ void canonicalTagNameMapsAnOpenTelemetryRenameToItsDatadogName() { assertEquals("my.custom.tag", KnownTagCodec.canonicalTagName("my.custom.tag")); } + @Test + void idOfRecoversEveryKnownTagsFullIdFromItsSerial() throws IllegalAccessException { + int checked = 0; + for (java.lang.reflect.Field field : KnownTags.class.getFields()) { + if (field.getType() == long.class && field.getName().endsWith("_ID")) { + long tagId = field.getLong(null); + if (KnownTagCodec.isKnown(tagId)) { + assertEquals(tagId, KnownTagCodec.idOf(KnownTagCodec.serialNum(tagId)), field.getName()); + checked++; + } + } + } + assertTrue(checked > 0); + } + @TableTest({ "scenario | kind | direction", "no kind | | -1 ", diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java index 4634a02da58..ecef85afdd4 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java @@ -72,40 +72,4 @@ void anUnknownIdIsRejected() { assertThrows(IllegalArgumentException.class, () -> map.set(0L, "value")); assertThrows(IllegalArgumentException.class, () -> map.set(KnownTagCodec.makeTagId(9999), 1)); } - - @Test - void theCoresTrustedSetsStoreWhatTheCheckedSetsStore() { - TagMap checked = TagMap.create(); - checked.set(KnownTags.HTTP_ROUTE_ID, "/users/{id}"); - checked.set(KnownTags.HTTP_STATUS_CODE_ID, 200); - checked.set(KnownTags.HTTP_RESEND_COUNT_ID, 2L); - checked.set(KnownTags.DD_PROFILING_ENABLED_ID, true); - - TagMap trusted = TagMap.create(); - trusted.internals().setKnown(KnownTags.HTTP_ROUTE_ID, "/users/{id}"); - trusted.internals().setKnown(KnownTags.HTTP_STATUS_CODE_ID, 200); - trusted.internals().setKnown(KnownTags.HTTP_RESEND_COUNT_ID, 2L); - trusted.internals().setKnown(KnownTags.DD_PROFILING_ENABLED_ID, true); - - assertEquals(new HashMap<>(checked), new HashMap<>(trusted)); - assertEquals(KnownTags.HTTP_ROUTE_ID, trusted.getEntry(KnownTags.HTTP_ROUTE_NAME).tagId()); - } - - @Test - void theCoresTrustedSetsCoverTheRemainingValueShapes() { - Object component = new StringBuilder("jdbc"); // not a String: exercises the Object overload - TagMap checked = TagMap.create(); - checked.set(KnownTags.COMPONENT_ID, component); - checked.set(KnownTags.DB_POOL_NAME_ID, 1.5f); - checked.set(KnownTags.DB_INSTANCE_ID, 2.25d); - - TagMap trusted = TagMap.create(); - trusted.internals().setKnown(KnownTags.COMPONENT_ID, component); - trusted.internals().setKnown(KnownTags.DB_POOL_NAME_ID, 1.5f); - trusted.internals().setKnown(KnownTags.DB_INSTANCE_ID, 2.25d); - - assertEquals(new HashMap<>(checked), new HashMap<>(trusted)); - assertEquals(1.5f, trusted.getEntry(KnownTags.DB_POOL_NAME_ID).floatValue()); - assertEquals(2.25d, trusted.getEntry(KnownTags.DB_INSTANCE_ID).doubleValue()); - } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java new file mode 100644 index 00000000000..97b5f64e415 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java @@ -0,0 +1,493 @@ +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * A TagMap's two stores -- an {@link IdStore} for known tags ({@link HashIdStore} unless dense mode + * is on), a {@link HashStore} for custom tags, created lazily -- the name-or-id routing over them, + * and the span's view ({@link TagMap#viewOf}). + */ +class TagStoresTest { + private static TagMap frozenParent() { + TagMap parent = TagMap.create(); + parent.set(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + parent.set("inherited.custom", "c"); + return parent.freeze(); + } + + @Test + void aMapWithNoCustomTagsHasNoCustomStore() { + TagMap tags = TagMap.create(); + tags.set(KnownTags.HTTP_ROUTE_ID, "/a"); + tags.set(KnownTags.COMPONENT_NAME, "jdbc"); + assertNull(tags.nameStore); + + tags.set("my.tag", "x"); + assertNotNull(tags.nameStore); + assertEquals(3, tags.size()); + } + + @Test + void aChildBorrowsItsParentsCustomStoreUntilItWritesOne() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + assertSame(parent.nameStore, child.nameStore); + assertTrue(child.nameStoreBorrowed); + assertEquals("c", child.getString("inherited.custom")); + + child.set(KnownTags.HTTP_METHOD_ID, "GET"); // a known write keeps borrowing + assertTrue(child.nameStoreBorrowed); + assertFalse(child.remove("not.there")); // nothing to hide keeps borrowing + assertTrue(child.nameStoreBorrowed); + + child.set("own.custom", "o"); + assertFalse(child.nameStoreBorrowed); + assertNotSame(parent.nameStore, child.nameStore); + assertEquals("c", child.getString("inherited.custom")); // still reads through + assertEquals(5, child.size()); + assertEquals("c", parent.getString("inherited.custom")); + assertNull(parent.getString("own.custom")); + } + + @Test + void removingABorrowedCustomTagHidesItLocally() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + + assertEquals("c", child.getAndRemove("inherited.custom").stringValue()); + + assertFalse(child.nameStoreBorrowed); + assertNull(child.getString("inherited.custom")); + assertEquals(2, child.size()); + assertEquals("c", parent.getString("inherited.custom")); + } + + @Test + void removingAnInheritedKnownTagTombstonesItWithoutCountingIt() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + + assertEquals("/inherited", child.getAndRemove(KnownTags.HTTP_ROUTE_ID).stringValue()); + assertNull(child.getAndRemove(KnownTags.HTTP_ROUTE_ID)); // already removed + + assertNull(child.getEntry(KnownTags.HTTP_ROUTE_ID)); + assertEquals(2, child.size()); + assertFalse(child.containsKey(KnownTags.HTTP_ROUTE_NAME)); + for (TagMap.EntryReader entry : child) { + assertNotEquals(KnownTags.HTTP_ROUTE_ID, entry.tagId()); + } + IdStore ids = child.knownStore; + assertEquals(0, ids.computeSize()); + assertNotEquals(0L, ids.tombstoneMask); + child.checkIntegrity(); + + // a re-set undoes the removal: no prior visible value + assertNull(child.getAndSet(KnownTags.HTTP_ROUTE_NAME, "/again")); + assertEquals("/again", child.getString(KnownTags.HTTP_ROUTE_NAME)); + assertEquals(3, child.size()); + } + + @Test + void tombstonesAreSharedPerTag() { + TagMap.Entry first = TagMap.Entry.tombstoneOf(KnownTags.HTTP_ROUTE_ID); + assertSame(first, TagMap.Entry.tombstoneOf(KnownTags.HTTP_ROUTE_ID)); + assertTrue(first.tombstone); + assertEquals(KnownTags.HTTP_ROUTE_NAME, first.tag()); + } + + /** A span-level known tag colored into {@code traceLevelId}'s slot, or 0 if there is none. */ + private static long spanLevelTagInSlotOf(long traceLevelId) throws IllegalAccessException { + for (java.lang.reflect.Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) continue; + long id = field.getLong(null); + if (KnownTagCodec.isKnown(id) + && IdStore.levelOf(id) == IdStore.SPAN_LEVEL + && KnownTagCodec.slotOf(id) == KnownTagCodec.slotOf(traceLevelId)) { + return id; + } + } + return 0L; + } + + @Test + void aSpanLevelReadSkipsAParentHoldingOnlyTraceLevelTagsInItsSlot() throws Exception { + // Span-level and trace-level tags are colored independently, so their slots overlap: find a + // span-level tag that shares runtime-id's slot (the coloring decides which one). + long spanLevelId = spanLevelTagInSlotOf(KnownTags.RUNTIME_ID); + assumeTrue(spanLevelId != 0L, "no span-level tag shares runtime-id's slot"); + assertEquals(IdStore.TRACE_LEVEL, IdStore.levelOf(KnownTags.RUNTIME_ID)); + + HashIdStore traceLevel = new HashIdStore(null); + traceLevel.set(KnownTags.RUNTIME_ID, "abc"); + HashIdStore span = new HashIdStore(traceLevel); + long other = + spanLevelId == KnownTags.HTTP_ROUTE_ID ? KnownTags.COMPONENT_ID : KnownTags.HTTP_ROUTE_ID; + span.set(other, "/a"); + + long bit = IdStore.slotBit(spanLevelId); + assertFalse(span.mayInherit(bit, IdStore.SPAN_LEVEL)); + assertTrue(span.mayInherit(bit, IdStore.TRACE_LEVEL)); + assertFalse(traceLevel.mayHaveLocal(bit, IdStore.SPAN_LEVEL)); + assertNull(span.getEntryReader(spanLevelId)); + assertEquals("abc", span.getValue(KnownTags.RUNTIME_ID)); + } + + @Test + void aClearSlotBitIsADefiniteMiss() { + HashIdStore ids = new HashIdStore(null); + assertEquals(0L, ids.spanOccupancy); + assertNull(ids.getEntryReader(KnownTags.HTTP_ROUTE_ID)); + + ids.set(KnownTags.HTTP_ROUTE_ID, "/a"); + assertNotEquals(0L, ids.spanOccupancy & IdStore.slotBit(KnownTags.HTTP_ROUTE_ID)); + assertEquals("/a", ids.getValue(KnownTags.HTTP_ROUTE_ID)); + + // a child inherits its parent's slots, so a read-through miss can stop early + HashIdStore child = new HashIdStore(ids); + assertEquals(ids.spanOccupancy, child.inheritedMask); + assertEquals("/a", child.getValue(KnownTags.HTTP_ROUTE_ID)); + } + + @Test + void aTraceLevelTagDoesNotDefeatASpanLevelMissInTheSameSlot() { + // A local root span holds trace-level and span-level tags in one store, and the two levels are + // colored independently, so their slot numbers overlap. + for (IdStore ids : new IdStore[] {new HashIdStore(null), new DenseIdStore(null)}) { + ids.set(KnownTags.RUNTIME_ID, "abc"); + ids.set(KnownTags.HTTP_ROUTE_ID, "/a"); + long runtimeIdBit = IdStore.slotBit(KnownTags.RUNTIME_ID); + assertNotEquals(runtimeIdBit, IdStore.slotBit(KnownTags.HTTP_ROUTE_ID), "test precondition"); + + assertTrue(ids.mayHaveLocal(runtimeIdBit, IdStore.TRACE_LEVEL)); + assertFalse(ids.mayHaveLocal(runtimeIdBit, IdStore.SPAN_LEVEL), ids.getClass().getName()); + } + } + + @Test + void anUnknownIdIsRejected() { + HashIdStore ids = new HashIdStore(null); + assertThrows(IllegalArgumentException.class, () -> ids.getEntryReader(0L)); + assertThrows(IllegalArgumentException.class, () -> ids.remove(0L)); + } + + @Test + void theIdStoreSetsEveryValueShape() { + HashIdStore ids = new HashIdStore(null); + ids.set(KnownTags.COMPONENT_ID, (Object) new StringBuilder("jdbc")); + ids.set(KnownTags.HTTP_ROUTE_ID, "/a"); + ids.set(KnownTags.DD_PROFILING_ENABLED_ID, true); + ids.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + ids.set(KnownTags.HTTP_RESEND_COUNT_ID, 2L); + ids.set(KnownTags.DB_POOL_NAME_ID, 1.5f); + ids.set(KnownTags.DB_INSTANCE_ID, 2.25d); + + TagMap checked = TagMap.create(); + checked.set(KnownTags.COMPONENT_ID, ids.getEntryReader(KnownTags.COMPONENT_ID).objectValue()); + checked.set(KnownTags.HTTP_ROUTE_ID, "/a"); + checked.set(KnownTags.DD_PROFILING_ENABLED_ID, true); + checked.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + checked.set(KnownTags.HTTP_RESEND_COUNT_ID, 2L); + checked.set(KnownTags.DB_POOL_NAME_ID, 1.5f); + checked.set(KnownTags.DB_INSTANCE_ID, 2.25d); + + Map actual = new HashMap<>(); + ids.fillMapLocal(actual); + assertEquals(new HashMap<>(checked), actual); + assertEquals(7, ids.size()); + assertEquals(7, ids.computeSize()); + assertTrue(ids.toInternalString().contains(KnownTags.HTTP_ROUTE_NAME)); + } + + @Test + void putAllIntoAStoreWithTombstonesPutsOneAtATime() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove(KnownTags.HTTP_ROUTE_NAME); // a tombstone + + TagMap source = TagMap.create(); + source.set(KnownTags.HTTP_ROUTE_ID, "/from-source"); + source.set(KnownTags.HTTP_METHOD_ID, "GET"); + child.putAll(source); + + assertEquals("/from-source", child.getString(KnownTags.HTTP_ROUTE_NAME)); + assertEquals(4, child.size()); // route, method, status code, inherited custom + child.checkIntegrity(); + } + + @Test + void aCopyKeepsTombstonesAndBorrowing() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove(KnownTags.HTTP_ROUTE_NAME); + + TagMap copy = child.copy(); + assertNull(copy.getString(KnownTags.HTTP_ROUTE_NAME)); + assertTrue(copy.nameStoreBorrowed); + assertEquals(child.size(), copy.size()); + + copy.set(KnownTags.HTTP_ROUTE_NAME, "/copy"); + assertNull(child.getString(KnownTags.HTTP_ROUTE_NAME)); // independent + } + + @Test + void anIteratorWalksBothStoresAndTheirParents() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set(KnownTags.HTTP_METHOD_ID, "GET"); + child.set("own.custom", "o"); + child.remove("inherited.custom"); + + Map seen = new HashMap<>(); + for (TagMap.EntryReader entry : child) { + seen.put(entry.tag(), entry.objectValue()); + } + Map expected = new HashMap<>(); + expected.put(KnownTags.HTTP_ROUTE_NAME, "/inherited"); + expected.put(KnownTags.HTTP_STATUS_CODE_NAME, 200); + expected.put(KnownTags.HTTP_METHOD_NAME, "GET"); + expected.put("own.custom", "o"); + assertEquals(expected, seen); + assertEquals(expected, new HashMap<>(child)); + } + + @Test + void routingReadsKnownTagsByIdAndEverythingElseByName() { + IdStore ids = IdStore.create(null); + HashStore names = HashStore.create(null); + // a direction-scoped OpenTelemetry name is resolved at the OpenTelemetry boundary, not here + assertEquals(0L, KnownTagCodec.keyOf("server.address")); + + ids.set(KnownTags.HTTP_ROUTE_ID, "/a"); + names.set("my.tag", "custom"); + + TagMap view = TagMap.viewOf(ids, names, false); + assertEquals("/a", view.getEntry(KnownTags.HTTP_ROUTE_NAME).stringValue()); + assertEquals("custom", view.getEntry("my.tag").stringValue()); + assertNull(TagMap.viewOf(ids, null, false).getEntry("my.tag")); // no custom store yet + } + + @Test + void anOwnerReadsThroughItsParentsStores() { + assertNull(TagMap.readThroughParent(null)); + assertThrows(IllegalStateException.class, () -> TagMap.readThroughParent(TagMap.create())); + assertNull(TagMap.readThroughParent(TagMap.create().freeze())); // empty parent dropped + + TagMap parent = TagMap.readThroughParent(frozenParent()); + IdStore ids = IdStore.create(parent); + HashStore borrowed = HashStore.inheritedFrom(parent); + assertSame(parent.nameStore, borrowed); + assertEquals("/inherited", ids.getValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals("c", borrowed.get("inherited.custom").stringValue()); + + HashStore own = HashStore.create(borrowed); + own.set("own", "o"); + assertEquals("c", own.get("inherited.custom").stringValue()); + assertNull(borrowed.get("own")); + } + + @Test + void theCustomStoreSetsEveryValueShape() { + HashStore names = HashStore.create(null); + names.set("o", (Object) "obj"); + names.set("s", "v"); + names.set("b", true); + names.set("i", 1); + names.set("l", 2L); + names.set("f", 3f); + names.set("d", 4d); + assertNull(names.getAndPut(TagMap.Entry.create("p", "x"))); + assertEquals("x", names.put(TagMap.Entry.create("p", "y")).stringValue()); + + assertEquals(true, names.get("b").objectValue()); + assertEquals(4d, names.get("d").objectValue()); + assertEquals(8, names.size()); + } + + @Test + void aViewWritesThroughToItsOwnersStoresAndHandsBackANewCustomStore() { + TagMap parent = TagMap.readThroughParent(frozenParent()); + IdStore ids = IdStore.create(parent); + HashStore borrowed = HashStore.inheritedFrom(parent); + TagMap view = TagMap.viewOf(ids, borrowed, true); + assertSame(borrowed, view.customStore()); + assertTrue(view.isCustomStoreBorrowed()); + + view.set(KnownTags.HTTP_METHOD_NAME, "GET"); + view.set("own", "o"); + assertEquals("GET", ids.getValue(KnownTags.HTTP_METHOD_ID)); + assertFalse(view.isCustomStoreBorrowed()); + assertNotSame(borrowed, view.customStore()); + assertEquals("o", view.customStore().get("own").stringValue()); + } + + @Test + void clearDetachesBothStores() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("own", "o"); + child.clear(); + assertTrue(child.isEmpty()); + assertNull(child.nameStore); + assertNull(child.getString("inherited.custom")); + assertTrue(child.toInternalString().contains("nameStore:")); + } + + /** Every known tag's real id, from the registry. */ + private static java.util.List knownIds() { + java.util.List ids = new java.util.ArrayList<>(); + for (int serial = 1; serial < KnownTagCodec.serialLimit(); serial++) { + long id = KnownTagCodec.keyOf(KnownTagCodec.nameOf(KnownTagCodec.makeTagId(serial))); + if (id > 0) { + ids.add(id); + } + } + return ids; + } + + /** + * A three-level chain with enough tags in each store to fill bucket groups, checked against a + * HashMap model through every read path: lookups, size, emptiness, iteration and forEach. + */ + @Test + void aDeepChainReadsLikeItsModel() { + java.util.List ids = knownIds(); + Map model = new HashMap<>(); + + TagMap grandparent = TagMap.create(); + for (int i = 0; i < ids.size(); i += 2) { + grandparent.set(ids.get(i), "gp" + i); + model.put(KnownTagCodec.nameOf(ids.get(i)), "gp" + i); + } + for (int i = 0; i < 40; i++) { + grandparent.set("custom." + i, "gp" + i); + model.put("custom." + i, "gp" + i); + } + grandparent.freeze(); + + TagMap parent = TagMap.createFromParent(grandparent); + for (int i = 1; i < ids.size(); i += 4) { + parent.set(ids.get(i), "p" + i); + model.put(KnownTagCodec.nameOf(ids.get(i)), "p" + i); + } + for (int i = 0; i < 40; i += 3) { + parent.set("custom." + i, "p" + i); + model.put("custom." + i, "p" + i); + } + parent.remove(KnownTagCodec.nameOf(ids.get(0))); // tombstone a grandparent known tag + model.remove(KnownTagCodec.nameOf(ids.get(0))); + parent.remove("custom.1"); + model.remove("custom.1"); + parent.freeze(); + + TagMap child = TagMap.createFromParent(parent); + for (int i = 0; i < ids.size(); i += 3) { + child.set(ids.get(i), "c" + i); + model.put(KnownTagCodec.nameOf(ids.get(i)), "c" + i); + } + for (int i = 2; i < ids.size(); i += 5) { + String name = KnownTagCodec.nameOf(ids.get(i)); + child.remove(name); + model.remove(name); + } + for (int i = 0; i < 40; i += 4) { + child.set("custom." + i, "c" + i); + model.put("custom." + i, "c" + i); + } + child.remove("custom.2"); + model.remove("custom.2"); + + assertEquals(model.size(), child.size()); + assertFalse(child.isEmpty()); + assertFalse(child.isDefinitelyEmpty()); + assertTrue(child.estimateSize() >= model.size()); + assertEquals(model, new HashMap<>(child)); + for (Map.Entry entry : model.entrySet()) { + assertEquals(entry.getValue(), child.getObject(entry.getKey()), entry.getKey()); + } + + Map viaConsumer = new HashMap<>(); + child.forEach(entry -> viaConsumer.put(entry.tag(), entry.objectValue())); + assertEquals(model, viaConsumer); + Map viaBi = new HashMap<>(); + child.forEach(viaBi, (m, entry) -> m.put(entry.tag(), entry.objectValue())); + assertEquals(model, viaBi); + Map viaTri = new HashMap<>(); + child.forEach(viaTri, "", (m, unused, entry) -> m.put(entry.tag(), entry.objectValue())); + assertEquals(model, viaTri); + Map filled = new HashMap<>(); + child.fillMap(filled); + assertEquals(model, filled); + + TagMap flat = TagMap.create(); + flat.set("custom.0", "flat"); + flat.set(ids.get(0), "flat"); + flat.putAll(child); // a read-through source: copied as its visible union + assertEquals(model, new HashMap<>(flat)); + flat.checkIntegrity(); + } + + @Test + void aChildThatHidesEverythingInheritedIsEmpty() { + TagMap parent = TagMap.create(); + parent.set(KnownTags.HTTP_ROUTE_ID, "/a"); + parent.set("c", "x"); + TagMap child = TagMap.createFromParent(parent.freeze()); + assertFalse(child.isEmpty()); + + child.remove(KnownTags.HTTP_ROUTE_NAME); + assertFalse(child.isEmpty()); + child.remove("c"); + assertTrue(child.isEmpty()); + assertEquals(0, child.size()); + assertTrue(child.isDefinitelyEmpty() == false); // the parent still holds local entries + } + + @Test + void removingALocalOverrideOfAnInheritedTagReturnsTheLocalValue() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set(KnownTags.HTTP_ROUTE_ID, "/local"); + child.set("inherited.custom", "local"); + + assertEquals("/local", child.getAndRemove(KnownTags.HTTP_ROUTE_ID).stringValue()); + assertEquals("local", child.getAndRemove("inherited.custom").stringValue()); + assertNull(child.getString(KnownTags.HTTP_ROUTE_NAME)); + assertNull(child.getString("inherited.custom")); + } + + @Test + void putAllMergesIntoNonEmptyStores() { + TagMap dest = TagMap.create(); + dest.set(KnownTags.HTTP_ROUTE_ID, "/dest"); + dest.set("d", "dest"); + TagMap source = TagMap.create(); + source.set(KnownTags.HTTP_METHOD_ID, "GET"); + source.set(KnownTags.HTTP_ROUTE_ID, "/source"); + source.set("s", "source"); + + dest.putAll(source); + + assertEquals("/source", dest.getString(KnownTags.HTTP_ROUTE_NAME)); + assertEquals("GET", dest.getString(KnownTags.HTTP_METHOD_NAME)); + assertEquals("source", dest.getString("s")); + assertEquals("dest", dest.getString("d")); + assertEquals(4, dest.size()); + dest.checkIntegrity(); + } + + @Test + void aCustomStoreRejectsAKnownTagsName() { + HashStore names = new HashStore(null); + assertThrows(AssertionError.class, () -> names.get(KnownTags.HTTP_ROUTE_NAME)); + assertThrows(AssertionError.class, () -> names.remove(KnownTags.HTTP_ROUTE_NAME)); + } +} diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index b9dc242c780..a6733fc287e 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -5852,6 +5852,14 @@ "aliases": [] } ], + "DD_TRACE_EXPERIMENTAL_DENSE_TAGS_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "false", + "aliases": [] + } + ], "DD_TRACE_EXPERIMENTAL_FEATURES_ENABLED": [ { "version": "A", 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: