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 c88b895281b..2085b062049 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 @@ -96,6 +96,7 @@ object KnownTagsEmitter { appendLine(" public static final String ${otelNameC(t.ddName)} = \"${escape(t.otelName)}\";") } append("// makeTagId(serial=${t.serial})") + append(if (t.slot == TagRegistry.NO_SLOT) " + no slot" else " + slot=${t.slot}") if (t.traceLevel) append(" + trace-level") if (t.intercepted) append(" + intercepted") if (t.scopedOtelNames.isNotEmpty()) append(" + scoped-alias") @@ -114,6 +115,9 @@ object KnownTagsEmitter { // Every known tag's serial is below SERIAL_LIMIT; serial 0 is no tag. val serialLimit = (reg.tags.maxOfOrNull { it.serial } ?: 0) + 1 appendLine(" public static final int SERIAL_LIMIT = $serialLimit;") + // Slots in use: every slotted tag's slot is below SLOT_COUNT. + val slotCount = (reg.tags.filter { it.slot != TagRegistry.NO_SLOT }.maxOfOrNull { it.slot } ?: -1) + 1 + appendLine(" public static final int SLOT_COUNT = $slotCount;") // OpenTelemetry name -> canonical tag name. Validation ensures aliases are distinct from all // canonical names. Sort by OTel name to keep output deterministic. diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt index 1a46bea5a4d..12dec86f681 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistry.kt @@ -2,12 +2,19 @@ package datadog.buildlogic.tagRegistry /** * Assigns tag ids from a parsed [TagConventions]. The id encoding mirrors KnownTagCodec: [63-48 - * serial][47-32 reserved][31-0 flags]. + * serial][47-32 slot][31-0 flags]. * - *

An id is a globally unique serial plus two classification bits: trace-level and intercepted. It - * carries no storage-layout coordinate -- bits [47-32] are held vacant for the co-occurrence slot - * that the dense tag store assigns by graph coloring, which lands with the dense store itself. - * Nothing here needs to know how (or whether) a tag is stored. + *

An id is a globally unique serial, a slot, and two classification bits: trace-level and + * intercepted. + * + *

The slot comes from GRAPH COLORING the tag co-occurrence graph. Each concrete span type's + * resolved tags appear together on one span, so they form a clique and need distinct slots; the + * trace-level tier is its own clique (it lives in a separate TagMap). Tags that never share a clique + * may share a slot, so the slot count stays bounded by the largest clique, small enough for one + * occupancy `long`. Every tag resolved onto a span type, or trace-level, is slotted whatever its + * requirement level; a tag on no modeled span type (the tracer overlay's routing-only keys, tags of + * unmodeled span types) gets [NO_SLOT]. Slots are for per-span structures that want an exact, + * compact presence word: the dense tag store (DenseTagMap), and in time a span builder's tag ledger. * *

The intercepted bit marks a tag the tracer may route on the set-path (to a span field or a * sampling directive instead of tag storage), as listed by the tracer overlay. A setter called with @@ -26,6 +33,8 @@ class TagRegistry private constructor(val tags: List) { val serial: Int, val traceLevel: Boolean, val intercepted: Boolean, + /** The tag's slot, or [NO_SLOT] for a tag on no modeled span type. */ + val slot: Int, val id: Long, /** The tag's OpenTelemetry name in every direction; null when not renamed, or renamed per direction. */ val otelName: String? = null, @@ -43,6 +52,10 @@ class TagRegistry private constructor(val tags: List) { const val INTERCEPTED = 1L shl 1 // low-32 carve bit 1; mirrors KnownTagCodec.INTERCEPTED const val LEVEL_TRACE = 1L shl 2 // low-32 carve bit 2; mirrors KnownTagCodec.LEVEL_TRACE + const val SLOT_SHIFT = 32 // slot window [47-32]; mirrors KnownTagCodec.SLOT_SHIFT + const val NO_SLOT = 0xFFFF // all-ones slot: no modeled span type; mirrors KnownTagCodec.NO_SLOT + const val MAX_SLOTS = 63 // fits one occupancy long; bit 63 is left for unslotted tags to share + /** * Serials stay below 2^15, so bit 63 of an id is never set: every id is positive, clear of 0 * (unknown) and of KnownTagCodec's negative lookup sentinels. @@ -50,17 +63,24 @@ class TagRegistry private constructor(val tags: List) { const val SERIAL_CAPACITY = 1 shl 15 /** - * Mirrors KnownTagCodec.makeTagId(serial) + intercepted() + traceLevel() -- must stay in sync. + * Mirrors KnownTagCodec's layout -- must stay in sync: serial at [63-48], slot at [47-32], * HAS_SCOPED_ALIAS at bit 0 (the tag has an OpenTelemetry name in some directions only), - * INTERCEPTED at bit 1, LEVEL_TRACE at bit 2, other low bits and the reserved [47-32] window - * zero. Every id is made here, and the range check makes each one positive by construction. + * INTERCEPTED at bit 1, LEVEL_TRACE at bit 2, other low bits zero. Every id is made here, and + * the range check makes each one positive by construction. */ - fun encode(serial: Int, traceLevel: Boolean, intercepted: Boolean, hasScopedAlias: Boolean = false): Long { + fun encode( + serial: Int, + traceLevel: Boolean, + intercepted: Boolean, + hasScopedAlias: Boolean = false, + slot: Int = NO_SLOT, + ): Long { + require(slot == NO_SLOT || slot in 0 until MAX_SLOTS) { "slot $slot is outside [0, $MAX_SLOTS)" } require(serial in FIRST_SERIAL until SERIAL_CAPACITY) { "serial $serial is outside [$FIRST_SERIAL, $SERIAL_CAPACITY): more tags would make tag ids " + "negative, where KnownTagCodec's lookup sentinels live" } - var id = serial.toLong() shl 48 + var id = (serial.toLong() shl 48) or (slot.toLong() shl SLOT_SHIFT) if (traceLevel) id = id or LEVEL_TRACE if (intercepted) id = id or INTERCEPTED if (hasScopedAlias) id = id or HAS_SCOPED_ALIAS @@ -70,6 +90,7 @@ class TagRegistry private constructor(val tags: List) { fun build(conv: TagConventions): TagRegistry { val traceLevel = conv.traceLevelTags().map { it.ddName }.toSet() val renames = conv.otelMappings().groupBy { it.tag.ddName } + val slots = colorSlots(conv) // One tag per Datadog name, in a stable order (by name) so serials -- and therefore ids -- are // a pure function of the input. A name's declarations per direction merge into its one tag. @@ -83,6 +104,7 @@ class TagRegistry private constructor(val tags: List) { val isTraceLevel = first.ddName in traceLevel val isIntercepted = first.ddName in conv.interceptedNames val (otelName, scoped) = otelNamesOf(first.ddName, renames[first.ddName].orEmpty()) + val slot = slots[first.ddName] ?: NO_SLOT Tag( first.ddName, first.type, @@ -90,7 +112,8 @@ class TagRegistry private constructor(val tags: List) { serial, isTraceLevel, isIntercepted, - id = encode(serial, isTraceLevel, isIntercepted, scoped.isNotEmpty()), + slot, + id = encode(serial, isTraceLevel, isIntercepted, scoped.isNotEmpty(), slot), otelName = otelName, scopedOtelNames = scoped, ) @@ -100,6 +123,41 @@ class TagRegistry private constructor(val tags: List) { return TagRegistry(tags) } + /** + * Colors the co-occurrence graph of slotted tags: tags resolved onto one concrete span type, or + * both trace-level, must get distinct slots. Greedy, most-constrained first (by clique count, + * then name), so the result is a pure function of the conventions. A name declared per direction + * is one tag, so it is one vertex. + */ + private fun colorSlots(conv: TagConventions): Map { + val cliques = ArrayList>() + for (type in conv.concreteTypes()) { + cliques.add(conv.resolve(type).map { it.ddName }.toSet()) + } + cliques.add(conv.traceLevelTags().map { it.ddName }.toSet()) + + val neighbors = HashMap>() + for (clique in cliques) { + for (a in clique) { + val adjacent = neighbors.getOrPut(a) { HashSet() } + for (b in clique) if (a != b) adjacent.add(b) + } + } + val cliqueCount = neighbors.keys.associateWith { tag -> cliques.count { tag in it } } + val order = neighbors.keys.sortedWith(compareByDescending { cliqueCount.getValue(it) }.thenBy { it }) + val slots = HashMap() + for (tag in order) { + val taken = neighbors.getValue(tag).mapNotNull { slots[it] }.toSet() + var slot = 0 + while (slot in taken) slot++ + require(slot < MAX_SLOTS) { + "coloring needs more than $MAX_SLOTS slots at '$tag'; one occupancy long holds $MAX_SLOTS" + } + slots[tag] = slot + } + return slots + } + /** * A tag's renames, as one name for every direction or one name per direction. The rename of a * declaration made per direction applies in that declaration's direction. diff --git a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt index f2a44a962aa..2477a7b56f4 100644 --- a/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt +++ b/build-logic/tag-registry/src/main/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGenerator.kt @@ -70,16 +70,17 @@ object TagRegistryGenerator { """ # Tag id assignment. tags=${reg.tags.size} - # TAGS serial lvl int id required name + # TAGS serial lvl int slot id required name """.trimIndent() ) for (t in reg.tags) { appendLine( - " %6d %s %s %-18s %-12s %s".format( + " %6d %s %s %4s %-18s %-12s %s".format( Locale.ROOT, t.serial, if (t.traceLevel) "T" else "-", if (t.intercepted) "I" else "-", + if (t.slot == TagRegistry.NO_SLOT) "-" else t.slot.toString(), "0x%016X".format(Locale.ROOT, t.id), t.required, t.name diff --git a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt index 2169afd1908..0ea513e0ae9 100644 --- a/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt +++ b/build-logic/tag-registry/src/test/kotlin/datadog/buildlogic/tagRegistry/TagRegistryGeneratorTest.kt @@ -558,9 +558,9 @@ class TagRegistryGeneratorTest { val knownTags = contents(output).getValue("java/datadog/trace/api/KnownTags.java") assertThat(knownTags) .contains( - "COMPONENT_ID = 0x0001000000000000L", - "RESOURCE_NAME_ID = 0x0002000000000002L", - "SERVICE_ID = 0x0003000000000002L", + "COMPONENT_ID = 0x0001FFFF00000000L", + "RESOURCE_NAME_ID = 0x0002FFFF00000002L", + "SERVICE_ID = 0x0003FFFF00000002L", "public static final int SERVICE_SERIAL_NUM = 3;", "public static final int SERIAL_LIMIT = 4;" ) @@ -584,6 +584,44 @@ class TagRegistryGeneratorTest { .withMessageContaining(message) } + @Test + fun `tags on one span type get distinct slots and tags that never co-occur share one`() { + val yaml = directory.conventionsFile( + """ + trace_level: + tags: [{dd-name: env, required: required}] + span_types: + base: + abstract: true + tags: [{dd-name: service, required: required}] + http.server: + extends: base + span-kind: server + tags: [{dd-name: http.route, required: required}] + db.client: + extends: base + span-kind: client + tags: [{dd-name: db.type, required: required}, {dd-name: db.user}] + """ + ) + val output = File(directory, "generated") + + TagRegistryGenerator.generate(yaml, output) + + // service is on both span types, so it takes slot 0; db.type and db.user share db.client, so + // they take distinct slots, and http.route -- never on a span type with either -- reuses one of + // them; env is trace-level (its own TagMap) and reuses slot 0. Every level is slotted. + assertThat(contents(output).getValue("java/datadog/trace/api/KnownTags.java")) + .contains( + "DB_TYPE_ID = 0x0001000100000000L", + "DB_USER_ID = 0x0002000200000000L", + "ENV_ID = 0x0003000000000004L", + "HTTP_ROUTE_ID = 0x0004000100000000L", + "SERVICE_ID = 0x0005000000000000L", + "public static final int SLOT_COUNT = 3;" + ) + } + private fun tagConventions(@Language("yaml") yamlText: String) = TagConventions.parse( ObjectMapper(YAMLFactory()).readValue( directory.conventionsFile(yamlText), 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/core/SetTagBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagBenchmark.java index dfcbd2266d0..d787ec8db81 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SetTagBenchmark.java @@ -125,6 +125,15 @@ public Object knownIntTag_byId() { return span.setTag(KnownTags.HTTP_RESEND_COUNT_ID, 2); } + /** + * {@link #knownIntTag_byId} with a value outside {@code Integer}'s cache, so a box, if the path + * makes one, is a real allocation -- as for most status codes and ports. + */ + @Benchmark + public Object knownIntTag_byId_uncached() { + return span.setTag(KnownTags.HTTP_RESEND_COUNT_ID, 5432); + } + @Benchmark public Object knownIntTag_byName() { return span.setTag(KnownTags.HTTP_RESEND_COUNT_NAME, 2); diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationByIdBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationByIdBenchmark.java new file mode 100644 index 00000000000..4066d98fc28 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationByIdBenchmark.java @@ -0,0 +1,170 @@ +package datadog.trace.core; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.Tags; +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.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * {@link SpanCreationBenchmark}'s tagged span shapes, setting each tag by its {@link KnownTags} id + * instead of its name: same tags, values, threads and forks, so each arm here compares directly + * with the same-named arm there. The bare spans set no tags, and the builder path has no id-keyed + * {@code withTag}, so those arms are not repeated. + * + *

Read {@code gc.alloc.rate.norm} (B/op) first, as for {@link SpanCreationBenchmark}; throughput + * is directional. Run with {@code -prof gc}, and append {@code -DTEST_LOG_LEVEL=warn} to any {@code + * -jvmArgsAppend}, which replaces the {@code @Fork} arguments below. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") +public class SpanCreationByIdBenchmark { + private static final String INSTRUMENTATION_NAME = "bench"; + private static final String SERVER_OPERATION_NAME = "servlet.request"; + private static final String JDBC_OPERATION_NAME = "database.query"; + + // Same values as SpanCreationBenchmark; int values stay inside Integer's cache. + private static final String COMPONENT_VALUE = "tomcat-server"; + private static final String HTTP_METHOD_VALUE = "GET"; + private static final String HTTP_ROUTE_VALUE = "/owners/{ownerId}"; + private static final String HTTP_URL_VALUE = "http://localhost:8080/owners/42"; + private static final int HTTP_STATUS_VALUE = 100; + private static final int PEER_PORT_VALUE = 80; + + private static final String DB_COMPONENT_VALUE = "java-jdbc-statement"; + private static final String DB_TYPE_VALUE = "postgresql"; + private static final String DB_INSTANCE_VALUE = "petclinic"; + private static final String DB_USER_VALUE = "app"; + private static final String DB_OPERATION_VALUE = "SELECT"; + private static final String DB_STATEMENT_VALUE = "SELECT * FROM owners WHERE id = ?"; + private static final String DB_PEER_HOSTNAME_VALUE = "db.internal"; + private static final int DB_PEER_PORT_VALUE = 90; + + CoreTracer tracer; + SpanPrototype webProto; + SpanPrototype jdbcProto; + + @Setup + public void setup(Blackhole blackhole) { + this.tracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build(); + this.webProto = + SpanPrototype.builder() + .initInstrumentationName(INSTRUMENTATION_NAME) + .initOperationName(SERVER_OPERATION_NAME) + .initComponentOnly(COMPONENT_VALUE) + .initKind(Tags.SPAN_KIND_SERVER) + .build(); + this.jdbcProto = + SpanPrototype.builder() + .initInstrumentationName(INSTRUMENTATION_NAME) + .initOperationName(JDBC_OPERATION_NAME) + .initComponentOnly(DB_COMPONENT_VALUE) + .initKind(Tags.SPAN_KIND_CLIENT) + .initTag(Tags.DB_TYPE, DB_TYPE_VALUE) + .build(); + } + + @TearDown + public void tearDown() { + this.tracer.close(); + } + + /** {@link SpanCreationBenchmark#webServerSpan}, by id. */ + @Benchmark + public void webServerSpan() { + AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME).start(); + span.setTag(KnownTags.COMPONENT_ID, COMPONENT_VALUE); + span.setTag(KnownTags.SPAN_KIND_ID, Tags.SPAN_KIND_SERVER); + span.setTag(KnownTags.HTTP_METHOD_ID, HTTP_METHOD_VALUE); + span.setTag(KnownTags.HTTP_ROUTE_ID, HTTP_ROUTE_VALUE); + span.setTag(KnownTags.HTTP_URL_ID, HTTP_URL_VALUE); + span.setTag(KnownTags.HTTP_STATUS_CODE_ID, HTTP_STATUS_VALUE); + span.setTag(KnownTags.PEER_PORT_ID, PEER_PORT_VALUE); + span.finish(); + } + + /** {@link SpanCreationBenchmark#jdbcClientSpan}, by id. */ + @Benchmark + public void jdbcClientSpan() { + AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, JDBC_OPERATION_NAME).start(); + span.setTag(KnownTags.COMPONENT_ID, DB_COMPONENT_VALUE); + span.setTag(KnownTags.SPAN_KIND_ID, Tags.SPAN_KIND_CLIENT); + span.setTag(KnownTags.DB_TYPE_ID, DB_TYPE_VALUE); + span.setTag(KnownTags.DB_INSTANCE_ID, DB_INSTANCE_VALUE); + span.setTag(KnownTags.DB_USER_ID, DB_USER_VALUE); + span.setTag(KnownTags.DB_OPERATION_ID, DB_OPERATION_VALUE); + span.setTag(KnownTags.DB_STATEMENT_ID, DB_STATEMENT_VALUE); + span.setTag(KnownTags.PEER_HOSTNAME_ID, DB_PEER_HOSTNAME_VALUE); + span.setTag(KnownTags.PEER_PORT_ID, DB_PEER_PORT_VALUE); + span.finish(); + } + + /** {@link SpanCreationBenchmark#webServerSpanViaPrototype}, by id. */ + @Benchmark + public void webServerSpanViaPrototype() { + AgentSpan span = tracer.buildSpan(webProto, null).start(); + span.setTag(KnownTags.HTTP_METHOD_ID, HTTP_METHOD_VALUE); + span.setTag(KnownTags.HTTP_ROUTE_ID, HTTP_ROUTE_VALUE); + span.setTag(KnownTags.HTTP_URL_ID, HTTP_URL_VALUE); + span.setTag(KnownTags.HTTP_STATUS_CODE_ID, HTTP_STATUS_VALUE); + span.setTag(KnownTags.PEER_PORT_ID, PEER_PORT_VALUE); + span.finish(); + } + + /** {@link SpanCreationBenchmark#jdbcClientSpanViaPrototype}, by id. */ + @Benchmark + public void jdbcClientSpanViaPrototype() { + AgentSpan span = tracer.buildSpan(jdbcProto, null).start(); + span.setTag(KnownTags.DB_INSTANCE_ID, DB_INSTANCE_VALUE); + span.setTag(KnownTags.DB_USER_ID, DB_USER_VALUE); + span.setTag(KnownTags.DB_OPERATION_ID, DB_OPERATION_VALUE); + span.setTag(KnownTags.DB_STATEMENT_ID, DB_STATEMENT_VALUE); + span.setTag(KnownTags.PEER_HOSTNAME_ID, DB_PEER_HOSTNAME_VALUE); + span.setTag(KnownTags.PEER_PORT_ID, DB_PEER_PORT_VALUE); + span.finish(); + } + + /** {@link SpanCreationBenchmark#webServerSpanViaPrototypeStartSpan}, by id. */ + @Benchmark + public void webServerSpanViaPrototypeStartSpan() { + AgentSpan span = tracer.startSpan(webProto, null); + span.setTag(KnownTags.HTTP_METHOD_ID, HTTP_METHOD_VALUE); + span.setTag(KnownTags.HTTP_ROUTE_ID, HTTP_ROUTE_VALUE); + span.setTag(KnownTags.HTTP_URL_ID, HTTP_URL_VALUE); + span.setTag(KnownTags.HTTP_STATUS_CODE_ID, HTTP_STATUS_VALUE); + span.setTag(KnownTags.PEER_PORT_ID, PEER_PORT_VALUE); + span.finish(); + } + + /** {@link SpanCreationBenchmark#jdbcClientSpanViaPrototypeStartSpan}, by id. */ + @Benchmark + public void jdbcClientSpanViaPrototypeStartSpan() { + AgentSpan span = tracer.startSpan(jdbcProto, null); + span.setTag(KnownTags.DB_INSTANCE_ID, DB_INSTANCE_VALUE); + span.setTag(KnownTags.DB_USER_ID, DB_USER_VALUE); + span.setTag(KnownTags.DB_OPERATION_ID, DB_OPERATION_VALUE); + span.setTag(KnownTags.DB_STATEMENT_ID, DB_STATEMENT_VALUE); + span.setTag(KnownTags.PEER_HOSTNAME_ID, DB_PEER_HOSTNAME_VALUE); + span.setTag(KnownTags.PEER_PORT_ID, DB_PEER_PORT_VALUE); + span.finish(); + } +} 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..4acca3f17b3 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 @@ -42,6 +42,7 @@ import datadog.trace.api.IdGenerationStrategy; import datadog.trace.api.InstrumenterConfig; import datadog.trace.api.Pair; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; import datadog.trace.api.TraceConfig; import datadog.trace.api.civisibility.config.BazelMode; @@ -2328,6 +2329,17 @@ protected static final DDSpanContext buildSpanContext( requestContextDataIast = builderRequestContextDataIast; } + // Per-operation dense-store sizing: an entry (local-root) span carries the trace-metadata / + // enriching tags a child doesn't, so pick the lane by whether we have a local parent. A + // resolved hint sizes the span's TagMap and self-tunes as spans finish; null (dense store + // off, or no operation name) falls back to the generic default capacity. forOperation tests + // the dense-store flag first, so with it off -- the default -- this constant-folds to null + // and + // the default span-creation path does no lookup (do no harm). + final SizingHint sizingHint = + SizingHint.forOperation( + operationName, !(resolvedParentSpanContext instanceof DDSpanContext)); + // some attributes are inherited from the parent context = new DDSpanContext( @@ -2356,7 +2368,8 @@ protected static final DDSpanContext buildSpanContext( tracer.profilingContextIntegration, tracer.injectBaggageAsTags, tracer.injectLinksAsTags, - mergedTracerTagsNeedsIntercept ? null : mergedTracerTags); + mergedTracerTagsNeedsIntercept ? null : mergedTracerTags, + sizingHint); // 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: diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java index e15a7ed49cb..de5333e21db 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java @@ -178,6 +178,8 @@ private void finishAndAddToTrace(final long durationNano) { wrapper.onSpanFinished(); } this.metrics.onSpanFinished(); + // every finished span tunes its operation's sizing hint, serialized or not + context.recordDenseSize(); TraceCollector.PublishState publishState = context.getTraceCollector().onPublish(this); log.debug("Finished span ({}): {}", publishState, this); } 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 cfe876c1edb..5daf6ba2c2d 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 @@ -18,6 +18,7 @@ import datadog.trace.api.KnownTagCodec; import datadog.trace.api.KnownTags; import datadog.trace.api.ProcessTags; +import datadog.trace.api.SizingHint; import datadog.trace.api.SpanTags; import datadog.trace.api.TagMap; import datadog.trace.api.cache.DDCache; @@ -58,6 +59,7 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -180,6 +182,15 @@ public class DDSpanContext */ private final TagMap unsafeTags; + /** + * Per-operation sizing hint (from SizingHintTable, keyed by operation name) this span's dense + * store was sized from, if any. Held so the span can feed its dense-store size back when it + * finishes and when it is serialized (recordDenseSize) -- the self-tuning loop that lets the + * reused hint converge to the operation's real known-tag high-water mark. Null when unsized, + * which is always the case with the dense store off. + */ + @Nullable private final SizingHint sizingHint; + /** The service name is required, otherwise the span are dropped by the agent */ private volatile String serviceName; @@ -418,6 +429,64 @@ public DDSpanContext( final boolean injectBaggageAsTags, final boolean injectLinksAsTags, final TagMap readThroughParent) { + this( + traceId, + spanId, + parentId, + parentServiceName, + serviceNameSource, + serviceName, + operationName, + resourceName, + samplingPriority, + origin, + baggageItems, + w3cBaggage, + errorFlag, + spanType, + tagsSize, + traceCollector, + requestContextDataAppSec, + requestContextDataIast, + CiVisibilityContextData, + pathwayContext, + disableSamplingMechanismValidation, + propagationTags, + profilingContextIntegration, + injectBaggageAsTags, + injectLinksAsTags, + readThroughParent, + null); + } + + public DDSpanContext( + final DDTraceId traceId, + final long spanId, + final long parentId, + final CharSequence parentServiceName, + final CharSequence serviceNameSource, + final String serviceName, + final CharSequence operationName, + final CharSequence resourceName, + final int samplingPriority, + final CharSequence origin, + final Map baggageItems, + final Baggage w3cBaggage, + final boolean errorFlag, + final CharSequence spanType, + final int tagsSize, + final TraceCollector traceCollector, + final Object requestContextDataAppSec, + final Object requestContextDataIast, + final Object CiVisibilityContextData, + final PathwayContext pathwayContext, + final boolean disableSamplingMechanismValidation, + final PropagationTags propagationTags, + final ProfilingContextIntegration profilingContextIntegration, + final boolean injectBaggageAsTags, + final boolean injectLinksAsTags, + final TagMap readThroughParent, + @Nullable final SizingHint sizingHint) { assert traceCollector != null; this.traceCollector = traceCollector; @@ -448,8 +517,9 @@ public DDSpanContext( final int capacity = Math.max((tagsSize <= 0 ? 3 : (tagsSize + 1)) * 4 / 3, 8); this.unsafeTags = readThroughParent != null - ? TagMap.createFromParent(readThroughParent) - : TagMap.create(capacity); + ? TagMap.createFromParent(readThroughParent, sizingHint) + : sizingHint != null ? TagMap.create(sizingHint) : TagMap.create(capacity); + this.sizingHint = sizingHint; // must set this before setting the service and resource names below this.profilingContextIntegration = profilingContextIntegration; @@ -1269,8 +1339,8 @@ public void setTag(final long tagId, final boolean value) { if (!KnownTagCodec.isKnown(tagId)) { return; } - if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) { - this.setBox(tagId, value); + if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) { + setKnownBox(tagId, value); // the one boxing site } else { synchronized (unsafeTags) { unsafeTags.internals().setKnown(tagId, value); @@ -1282,8 +1352,8 @@ public void setTag(final long tagId, final int value) { if (!KnownTagCodec.isKnown(tagId)) { return; } - if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) { - this.setBox(tagId, value); + if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) { + setKnownBox(tagId, value); // the one boxing site } else { synchronized (unsafeTags) { unsafeTags.internals().setKnown(tagId, value); @@ -1295,8 +1365,8 @@ public void setTag(final long tagId, final long value) { if (!KnownTagCodec.isKnown(tagId)) { return; } - if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) { - this.setBox(tagId, value); + if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) { + setKnownBox(tagId, value); // the one boxing site } else { synchronized (unsafeTags) { unsafeTags.internals().setKnown(tagId, value); @@ -1308,8 +1378,8 @@ public void setTag(final long tagId, final float value) { if (!KnownTagCodec.isKnown(tagId)) { return; } - if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) { - this.setBox(tagId, value); + if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) { + setKnownBox(tagId, value); // the one boxing site } else { synchronized (unsafeTags) { unsafeTags.internals().setKnown(tagId, value); @@ -1321,8 +1391,8 @@ public void setTag(final long tagId, final double value) { if (!KnownTagCodec.isKnown(tagId)) { return; } - if (tagInterceptor.needsIntercept(tagId, routedTagFlags)) { - this.setBox(tagId, value); + if (TagMap.Internals.STORES_BOXES || tagInterceptor.needsIntercept(tagId, routedTagFlags)) { + setKnownBox(tagId, value); // the one boxing site } else { synchronized (unsafeTags) { unsafeTags.internals().setKnown(tagId, value); @@ -1330,6 +1400,21 @@ public void setTag(final long tagId, final double value) { } } + /** + * The boxed path of the primitive id setters, which box at most once: always for the dense store, + * which keeps the box ({@code STORES_BOXES} is a static final, so the test folds), and for a tag + * that may be intercepted, since the interceptor takes an Object. The hash store's plain path + * stores the primitive unboxed. One shared path keeps each setter's compiled code small. + */ + private void setKnownBox(long tagId, Object box) { + if (!tagInterceptor.needsIntercept(tagId, routedTagFlags) + || !tagInterceptor.interceptTag(this, tagId, box)) { + synchronized (unsafeTags) { + unsafeTags.internals().setKnown(tagId, box); + } + } + } + public void setTag(TagMap.EntryReader entry) { if (entry == null) { return; @@ -1343,26 +1428,6 @@ public void setTag(TagMap.EntryReader entry) { } } - /* - * Used when the interceptor's precheck says the tag may be intercepted, so boxing is unavoidable - * - * Either because the tagInterceptor needs to be fully checked (which requires boxing) - * In that case, a box has already been created so it makes sense to pass the box - * onto TagMap, since optimized TagMap will cache the box - * - * -- OR -- - * - * The TagMap isn't optimized and will need to box the primitive regardless of - * tag interception - */ - private void setBox(long tagId, Object box) { - if (!tagInterceptor.interceptTag(this, tagId, box)) { - synchronized (unsafeTags) { - unsafeTags.internals().setKnown(tagId, box); - } - } - } - public void setTag(final String tag, final boolean value) { if (null == tag) { return; @@ -1624,6 +1689,21 @@ void earlyProcessTags(AppendableSpanLinks links) { * @param restrictedSpan The span to read links and events from. * @param options The {@link CoreSpan} options describing what the serialization protocol needs. */ + /** + * Feeds this span's known-tag count back into its operation's {@link SizingHint}, so later spans + * of the operation are presized. Called when the span finishes, so every span tunes its hint even + * if it is never serialized (dropped by sampling, say), and again at serialization, after the + * serialization-time tags are added, so the hint also covers the span's final footprint. At + * finish the {@code knownCount} read is unlocked and may be stale; the hint is a best-effort racy + * max anyway (see {@link TagMap#recordSize}). With the dense store off the hint is always null, + * so this is a single null check. + */ + void recordDenseSize() { + if (sizingHint != null) { + unsafeTags.recordSize(sizingHint); + } + } + void processTagsAndBaggage( final MetadataConsumer consumer, int longRunningVersion, DDSpan restrictedSpan, int options) { boolean injectLinksAsTags = this.injectLinksAsTags && (options & STRUCTURED_LINKS) == 0; @@ -1665,6 +1745,10 @@ void processTagsAndBaggage( } } + // Self-tune the per-operation sizing hint now that every tag (incl. the just-appended + // serialization-time tags) is present -- the terminal point shared by all finish modes. + recordDenseSize(); + // Baggage Map baggageItemsWithPropagationTags; if (this.injectBaggageAsTags) { diff --git a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java index a836404f0fb..861dc87d6f1 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java @@ -2,6 +2,7 @@ import static datadog.trace.api.DDTags.ANALYTICS_SAMPLE_RATE; import static datadog.trace.api.KnownTags.COMPONENT_ID; +import static datadog.trace.api.KnownTags.LANGUAGE_ID; import static datadog.trace.api.KnownTags.SERVLET_CONTEXT_ID; import static datadog.trace.api.sampling.PrioritySampling.USER_DROP; import static datadog.trace.bootstrap.instrumentation.api.ServiceNameSources.SPLIT_BY_SERVLET_CONTEXT; @@ -361,10 +362,10 @@ public void resolveSplitByTagsPrecedence(DDSpanContext span, TagMap prototypeTag return; } } - if (isSplitServiceTag(DDTags.LANGUAGE_TAG_KEY)) { - Object language = prototypeTags.getObject(DDTags.LANGUAGE_TAG_KEY); + if (isSplitServiceTag(LANGUAGE_ID)) { + TagMap.Entry language = prototypeTags.getEntry(LANGUAGE_ID); if (language != null) { - splitService(span, language); + splitService(span, language.objectValue()); } } } 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 b01b188d512..e6d088a99dc 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 @@ -376,6 +376,36 @@ void splitByTagsDecoratorComponentOverwritesCandidateSetAtConstruction() { assertEquals("my-component", span.getServiceName()); } + /** + * Without a {@code component}, a prototype's {@code language} is re-applied after the prototype's + * tags are intercepted, so it wins over another split-by-tags candidate the prototype sets. + * Called directly: the prototype's map is intercepted in hash order, so a full application can't + * pin the order that needs this. + */ + @Test + void splitByTagsLanguageIsReappliedWithoutAComponent() { + TagInterceptor interceptor = + new TagInterceptor( + true, + "my-service", + new HashSet<>(Arrays.asList("tenant", DDTags.LANGUAGE_TAG_KEY)), + new RuleFlags(), + false); + CoreTracer tracer = + tracerBuilder().writer(new LoggingWriter()).tagInterceptor(interceptor).build(); + AgentSpan span = tracer.buildSpan("datadog", "some span").start(); + span.setTag("tenant", "acme"); // as if the prototype's tenant was intercepted last + assertEquals("acme", span.getServiceName()); + + TagMap prototypeTags = TagMap.create(); + prototypeTags.set(DDTags.LANGUAGE_TAG_KEY, DDTags.LANGUAGE_TAG_VALUE); + prototypeTags.set("tenant", "acme"); + interceptor.resolveSplitByTagsPrecedence((DDSpanContext) span.spanContext(), prototypeTags); + span.finish(); + + assertEquals(DDTags.LANGUAGE_TAG_VALUE, span.getServiceName()); + } + /** Separate tag writes stay last-writer-wins, whichever split-by-tags candidate they set. */ @Test void splitByTagsSeparateWritesAreLastWriterWins() { diff --git a/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java new file mode 100644 index 00000000000..d9906a27606 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java @@ -0,0 +1,150 @@ +package datadog.trace.api; + +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Allocation per span-sized TagMap with the dense store enabled. Run with {@code -prof gc} and read + * {@code gc.alloc.rate.norm} (B/op): allocation is deterministic, throughput here is not tuned. + * + *

Inputs: {@code tagCount} tags per map, drawn from the slotted known tags of an HTTP server + * span (dense) and custom names (bucketed). {@code scenario} sets the mix: {@code custom} (all + * bucketed -- the baseline), {@code mixed} (about 70% slotted), {@code allSlotted}. {@code + * buildMapById} sets the slotted tags by id, the path instrumentation is moving to. + * + *

The flag is per JVM, so this class forks with it on. For the store-off baseline, run with + * {@code -jvmArgsAppend -Ddd.trace.experimental.dense.tags.enabled=false}. + * + *

Results, B/op ({@code buildMap}; {@code buildMapById} matched it in every case), Zulu 17.0.7, + * Apple Silicon, {@code -prof gc -f 1 -wi 1 -i 2}, 2026-10-09: + * + *

+ * scenario     tagCount   store off   store on
+ * custom              7         472        472
+ * custom             12         808        808
+ * mixed               7         520        456
+ * mixed              12         856        504
+ * allSlotted          7         568        232
+ * allSlotted         12         856        232
+ * 
+ * + *

Custom tags are unchanged. With the store on, a map of up to 12 slotted tags costs the same + * (the dense arrays start at 12), and {@code buildAndForEach} adds a flat 16 B/op (one reused + * flyweight), not a per-tag Entry. + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Warmup(iterations = 2, time = 2) +@Measurement(iterations = 3, time = 2) +@Fork( + value = 1, + jvmArgsAppend = {"-Ddd.trace.experimental.dense.tags.enabled=true"}) +@Threads(1) +public class DenseStoreAllocBenchmark { + static final long[] SLOTTED_IDS = { + KnownTags.COMPONENT_ID, + KnownTags.SPAN_KIND_ID, + KnownTags.SERVICE_ID, + KnownTags.HTTP_METHOD_ID, + KnownTags.HTTP_URL_ID, + KnownTags.HTTP_ROUTE_ID, + KnownTags.HTTP_STATUS_CODE_ID, + KnownTags.HTTP_HOSTNAME_ID, + KnownTags.HTTP_USERAGENT_ID, + KnownTags.NETWORK_PROTOCOL_VERSION_ID, + KnownTags.DD_INTEGRATION_ID, + KnownTags.ERROR_TYPE_ID, + }; + + @Param({"custom", "mixed", "allSlotted"}) + String scenario; + + @Param({"7", "12"}) + int tagCount; + + private String[] names; + private long[] ids; // 0 for a custom tag + private String[] values; + + @Setup(Level.Trial) + public void setup() { + for (long id : SLOTTED_IDS) { + if (!KnownTagCodec.hasSlot(id)) { + throw new IllegalStateException(KnownTagCodec.nameOf(id) + " has no dense-store slot"); + } + } + int slotted; + if ("allSlotted".equals(scenario)) { + slotted = tagCount; + } else if ("mixed".equals(scenario)) { + slotted = (tagCount * 7) / 10; + } else { + slotted = 0; + } + this.names = new String[tagCount]; + this.ids = new long[tagCount]; + this.values = new String[tagCount]; + for (int i = 0; i < tagCount; i++) { + if (i < slotted) { + this.ids[i] = SLOTTED_IDS[i]; + this.names[i] = KnownTagCodec.nameOf(SLOTTED_IDS[i]); + } else { + this.names[i] = "custom.tag." + i; + } + this.values[i] = "value-" + i; + } + } + + @Benchmark + public TagMap buildMap() { + TagMap m = TagMap.create(); + for (int i = 0; i < tagCount; i++) { + m.set(names[i], values[i]); + } + return m; + } + + @Benchmark + public TagMap buildMapById() { + TagMap m = TagMap.create(); + for (int i = 0; i < tagCount; i++) { + long id = ids[i]; + if (id != 0) { + m.set(id, values[i]); + } else { + m.set(names[i], values[i]); + } + } + return m; + } + + @Benchmark + public void buildAndForEach(Blackhole bh) { + TagMap m = buildMap(); + m.forEach(reader -> bh.consume(reader.objectValue())); + bh.consume(m); + } + + @Benchmark + public void buildAndIterate(Blackhole bh) { + TagMap m = buildMap(); + for (TagMap.EntryReader reader : m) { + bh.consume(reader.objectValue()); + } + bh.consume(m); + } +} 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 3471750c347..bd1329223a9 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; @@ -3453,6 +3455,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 = @@ -5430,6 +5433,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/DenseTagMap.java b/internal-api/src/main/java/datadog/trace/api/DenseTagMap.java new file mode 100644 index 00000000000..0698e139bd4 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/DenseTagMap.java @@ -0,0 +1,620 @@ +package datadog.trace.api; + +import datadog.trace.api.function.TriConsumer; +import java.util.Arrays; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import javax.annotation.Nonnull; + +/** + * The dense/hash hybrid {@link TagMap}: known tags are stored densely, by id, with no per-tag + * {@link TagMap.Entry}; custom tags stay in the buckets. Used when the dense store is on; see + * {@link TagMapFactory}. + * + *

Each routing method handles a dense id here and defers everything else to {@link TagMap}'s + * hash-only behavior. Every map in a JVM is of the one implementation, so a parent is always a + * {@code DenseTagMap} too. + */ +final class DenseTagMap extends TagMap { + + /* + * Dense known-tag store (experimental; see TagMap.DenseStore.ENABLED, default off). + *

+ * With the flag on, every known tag is NOT stored as a bucket Entry. Its value lives in the + * insertion-ordered parallel arrays knownIds/knownValues instead, with no per-tag Entry object: + * that is the allocation win. The registry colors the tags of each span type into distinct slots + * (KnownTagCodec.slotOf); a known tag on no modeled span type has NO_SLOT, which shares occupancy + * bit 63. Custom tags stay in the buckets, exactly as before. + *

+ * Presence: knownOccupancy holds 1L << slot for every dense id ever added. A clear bit means the + * tag is definitely absent, so an append or a miss skips the scan. A set bit only means "maybe": + * the knownIds scan is authoritative, so two tags sharing a slot in one map (legal -- tags from + * span types that never co-occur can still both be set) still work. Bits are never cleared on + * remove; a stale bit costs a scan, never a wrong answer. knownTraceLevel records whether any + * trace-level id was added (trace and span tags reuse slot numbers), with the same superset rule. + *

+ * Disjointness: whether an id routes dense depends only on the id and the global flag, so in a + * given JVM a tag is always dense or always bucketed -- never both, in any map. Read-through + * therefore shadows within a region: an ancestor's dense value can only be shadowed by a nearer + * level's dense value of the same id (parentDenseVisible), an ancestor's bucket entry only by a + * nearer bucket entry (parentEntryVisible, unchanged). Tombstones (removedFromParent) are keyed by + * canonical name and apply to both regions. + *

+ * size counts bucket entries only; knownCount counts dense entries; the local total is + * size + knownCount. Buckets start on the shared EMPTY_BUCKETS and are copied on the first bucket write, so an all-dense map allocates no bucket array. + *

+ * Iteration (iterator(), forEach) emits a dense entry through a reused EntryReadingHelper + * flyweight: valid only until the next element, so call .entry() to retain one. Point reads + * (getEntry) materialize a fresh Entry. + */ + + /** Shared all-null buckets (length 16) that maps start on. */ + private static final Object[] EMPTY_BUCKETS = new Object[1 << 4]; + + /** + * Initial capacity of the dense arrays when no {@link SizingHint} presized them; the largest span + * type resolves about this many tags. + */ + static final int KNOWN_INIT_CAP = 12; + + /** + * Shared empty dense arrays a map starts on (and returns to on clear), so that one full-capacity + * test covers a map's first dense tag too. Never written: a full map spills to its own arrays. + */ + private static final long[] EMPTY_KNOWN_IDS = new long[0]; + + private static final Object[] EMPTY_KNOWN_VALUES = new Object[0]; + + private long[] knownIds = EMPTY_KNOWN_IDS; + private Object[] knownValues = EMPTY_KNOWN_VALUES; + private int knownCount; + private long knownOccupancy; + private boolean knownTraceLevel; + + @Override + TagMapFactory factory() { + return DenseTagMapFactory.INSTANCE; + } + + DenseTagMap(TagMap parent) { + // start on the shared empty buckets; the first bucket write copies them + super(parent, EMPTY_BUCKETS); + } + + /** + * Like {@link #DenseTagMap(TagMap)}, with the dense arrays presized from {@code hint} (see {@link + * SizingHintTable}). A {@code null} hint, or one not yet seeded (a racy reader can see 0), keeps + * the shared empty arrays, so the first dense tag allocates at {@link #KNOWN_INIT_CAP}. + */ + DenseTagMap(TagMap parent, SizingHint hint) { + super(parent, EMPTY_BUCKETS); + if (hint != null) { + int cap = hint.size; // read the racy field once + if (cap > 0) { + this.knownIds = new long[cap]; + this.knownValues = new Object[cap]; + } + } + } + + DenseTagMap(Object[] buckets, int size) { + super(buckets, size); + } + + @Override + int knownCount() { + return this.knownCount; + } + + @Override + public void recordSize(SizingHint hint) { + // Capped hints (the heterogeneous shared overflow) are fixed -- don't let unlike sharers grow + // them. Best-effort racy max (see TagMap#recordSize); benign plain-int write. + if (hint != null && !hint.capped) { + int count = this.knownCount; + if (count > hint.size) { + hint.size = count; + } + } + } + + @Override + int countDenseVisibleIn(TagMap ancestor) { + DenseTagMap dense = (DenseTagMap) ancestor; + long[] ancestorIds = dense.knownIds; + int count = 0; + for (int i = 0; i < dense.knownCount; ++i) { + if (this.parentDenseVisible(ancestorIds[i], dense)) count++; + } + return count; + } + + @Override + Entry getLocalEntry(String tag, long tagHash) { + long denseId = denseIdOf(tagHash); + if (denseId != 0) { + int i = this.knownIndexOf(denseId); + return i < 0 ? null : Entry.newAnyEntry(denseId, this.knownValues[i]); + } + return super.getLocalEntry(tag, tagHash); + } + + @Override + Object getKnownValue(long tagId) { + int i = this.knownIndexOf(tagId); + return i >= 0 ? this.knownValues[i] : super.getKnownValue(tagId); + } + + /** The buckets to write to, copying the shared empty buckets on the first write. */ + @Override + Object[] writableBuckets() { + Object[] b = this.buckets; + if (b == EMPTY_BUCKETS) { + b = new Object[1 << 4]; + this.buckets = b; + } + return b; + } + + @Override + Entry putEntry(@Nonnull Entry newEntry) { + long denseId = denseIdOf(newEntry.tagHash); + if (denseId != 0) { + return this.putDenseLocal(denseId, newEntry.objectValue()); + } + return super.putEntry(newEntry); + } + + @Override + public void set(long tagId, @Nonnull Object value) { + this.storeDenseLocal(requireKnownId(tagId), value); + } + + @Override + public void set(long tagId, @Nonnull CharSequence value) { + this.storeDenseLocal(requireKnownId(tagId), value); + } + + @Override + public void set(long tagId, boolean value) { + this.storeDenseLocal(requireKnownId(tagId), Boolean.valueOf(value)); + } + + @Override + public void set(long tagId, int value) { + this.storeDenseLocal(requireKnownId(tagId), Integer.valueOf(value)); + } + + @Override + public void set(long tagId, long value) { + this.storeDenseLocal(requireKnownId(tagId), Long.valueOf(value)); + } + + @Override + public void set(long tagId, float value) { + this.storeDenseLocal(requireKnownId(tagId), Float.valueOf(value)); + } + + @Override + public void set(long tagId, double value) { + this.storeDenseLocal(requireKnownId(tagId), Double.valueOf(value)); + } + + @Override + void setKnownTrusted(long tagId, @Nonnull Object value) { + this.storeDenseTrusted(tagId, value); + } + + @Override + void setKnownTrusted(long tagId, @Nonnull CharSequence value) { + this.storeDenseTrusted(tagId, value); + } + + @Override + void setKnownTrusted(long tagId, boolean value) { + this.storeDenseTrusted(tagId, Boolean.valueOf(value)); + } + + @Override + void setKnownTrusted(long tagId, int value) { + this.storeDenseTrusted(tagId, Integer.valueOf(value)); + } + + @Override + void setKnownTrusted(long tagId, long value) { + this.storeDenseTrusted(tagId, Long.valueOf(value)); + } + + @Override + void setKnownTrusted(long tagId, float value) { + this.storeDenseTrusted(tagId, Float.valueOf(value)); + } + + @Override + void setKnownTrusted(long tagId, double value) { + this.storeDenseTrusted(tagId, Double.valueOf(value)); + } + + @Override + void putAllMerge(TagMap that) { + DenseTagMap dense = (DenseTagMap) that; + // incoming dense values clobber existing ones, as incoming bucket entries do + for (int i = 0; i < dense.knownCount; ++i) { + this.storeKnownValue(dense.knownIds[i], dense.knownValues[i]); + } + super.putAllMerge(that); + } + + @Override + void putAllIntoEmptyMap(TagMap that) { + DenseTagMap dense = (DenseTagMap) that; + if (dense.knownCount > 0) { + // dense values are immutable objects, so the copy can share them. Into this map's own arrays + // when they already fit -- a map presized from a SizingHint -- else sized to what the source + // holds, not its capacity, so a source that grew to the bound doesn't hand every copy a + // full-bound array. + int count = dense.knownCount; + if (this.knownIds.length < count) { + int cap = Math.max(count, KNOWN_INIT_CAP); + this.knownIds = new long[cap]; + this.knownValues = new Object[cap]; + } + System.arraycopy(dense.knownIds, 0, this.knownIds, 0, count); + System.arraycopy(dense.knownValues, 0, this.knownValues, 0, count); + this.knownCount = dense.knownCount; + this.knownOccupancy = dense.knownOccupancy; + this.knownTraceLevel = dense.knownTraceLevel; + } + if (that.size == 0) { + return; // no bucket entries; stay on the shared empty buckets + } + super.putAllIntoEmptyMap(that); + } + + @Override + public void fillMap(Map map) { + super.fillMap(map); + if (this.parent == null) { // with a parent, super walked the visible union, dense included + for (int i = 0; i < this.knownCount; ++i) { + map.put(KnownTagCodec.nameOf(this.knownIds[i]), this.knownValues[i]); + } + } + } + + @Override + public void fillStringMap(Map stringMap) { + super.fillStringMap(stringMap); + if (this.parent == null) { // see fillMap + for (int i = 0; i < this.knownCount; ++i) { + stringMap.put( + KnownTagCodec.nameOf(this.knownIds[i]), + TagValueConversions.toString(this.knownValues[i])); + } + } + } + + @Override + Entry removeLocal(String tag, long tagHash) { + long denseId = denseIdOf(tagHash); + if (denseId != 0) { + return this.removeKnown(denseId); + } + return super.removeLocal(tag, tagHash); + } + + @Override + public void forEach(Consumer consumer) { + if (this.knownCount > 0) { + // dense values go through one reused flyweight -- no Entry per tag + EntryReadingHelper reader = new EntryReadingHelper(); + for (int i = 0; i < this.knownCount; ++i) { + reader.set(this.knownIds[i], this.knownValues[i]); + consumer.accept(reader); + } + } + super.forEach(consumer); + } + + @Override + public void forEach(T thisObj, BiConsumer consumer) { + if (this.knownCount > 0) { + EntryReadingHelper reader = new EntryReadingHelper(); + for (int i = 0; i < this.knownCount; ++i) { + reader.set(this.knownIds[i], this.knownValues[i]); + consumer.accept(thisObj, reader); + } + } + super.forEach(thisObj, consumer); + } + + @Override + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { + if (this.knownCount > 0) { + EntryReadingHelper reader = new EntryReadingHelper(); + for (int i = 0; i < this.knownCount; ++i) { + reader.set(this.knownIds[i], this.knownValues[i]); + consumer.accept(thisObj, otherObj, reader); + } + } + super.forEach(thisObj, otherObj, consumer); + } + + @Override + void forEachDenseVisibleIn(TagMap ancestor, Consumer consumer) { + DenseTagMap dense = (DenseTagMap) ancestor; + EntryReadingHelper reader = null; + for (int i = 0; i < dense.knownCount; ++i) { + long denseId = dense.knownIds[i]; + if (this.parentDenseVisible(denseId, dense)) { + if (reader == null) reader = new EntryReadingHelper(); + reader.set(denseId, dense.knownValues[i]); + consumer.accept(reader); + } + } + } + + @Override + void forEachDenseVisibleIn( + TagMap ancestor, T thisObj, BiConsumer consumer) { + DenseTagMap dense = (DenseTagMap) ancestor; + EntryReadingHelper reader = null; + for (int i = 0; i < dense.knownCount; ++i) { + long denseId = dense.knownIds[i]; + if (this.parentDenseVisible(denseId, dense)) { + if (reader == null) reader = new EntryReadingHelper(); + reader.set(denseId, dense.knownValues[i]); + consumer.accept(thisObj, reader); + } + } + } + + @Override + void forEachDenseVisibleIn( + TagMap ancestor, + T thisObj, + U otherObj, + TriConsumer consumer) { + DenseTagMap dense = (DenseTagMap) ancestor; + EntryReadingHelper reader = null; + for (int i = 0; i < dense.knownCount; ++i) { + long denseId = dense.knownIds[i]; + if (this.parentDenseVisible(denseId, dense)) { + if (reader == null) reader = new EntryReadingHelper(); + reader.set(denseId, dense.knownValues[i]); + consumer.accept(thisObj, otherObj, reader); + } + } + } + + @Override + boolean readDenseIfVisible(TagMap level, int index, EntryReadingHelper reader) { + DenseTagMap dense = (DenseTagMap) level; + long denseId = dense.knownIds[index]; + if (dense == this || this.parentDenseVisible(denseId, dense)) { + reader.set(denseId, dense.knownValues[index]); + return true; + } + return false; + } + + @Override + void clearLocal() { + this.buckets = EMPTY_BUCKETS; // never write to the shared empty buckets + this.size = 0; + this.knownIds = EMPTY_KNOWN_IDS; + this.knownValues = EMPTY_KNOWN_VALUES; + this.knownCount = 0; + this.knownOccupancy = 0L; + this.knownTraceLevel = false; + } + + @Override + void checkIntegrity() { + // dense store: every id slotted and unique, its slot bit set, the count within bounds + if (this.knownCount > 0) { + if (this.knownIds == null || this.knownCount > this.knownIds.length) { + throw new IllegalStateException("incorrect known count"); + } + for (int i = 0; i < this.knownCount; ++i) { + long denseId = this.knownIds[i]; + if (denseIdOf(denseId) == 0) { + throw new IllegalStateException("dense id that is not a known tag"); + } + if ((this.knownOccupancy & slotBit(denseId)) == 0) { + throw new IllegalStateException("dense id missing from occupancy"); + } + for (int j = i + 1; j < this.knownCount; ++j) { + if (this.knownIds[j] == denseId) { + throw new IllegalStateException("duplicate dense id"); + } + } + } + } + // disjointness: no bucket entry may be one that routes dense + for (EntryReader entry : this) { + if (entry instanceof Entry && denseIdOf(((Entry) entry).tagHash) != 0) { + throw new IllegalStateException("dense-routed tag stored in a bucket: " + entry.tag()); + } + } + super.checkIntegrity(); + } + + @Override + String toInternalString() { + StringBuilder ledger = new StringBuilder(super.toInternalString()); + for (int i = 0; i < this.knownCount; ++i) { + ledger + .append("[dense ") + .append(i) + .append("] = {") + .append(KnownTagCodec.nameOf(this.knownIds[i])) + .append('=') + .append(this.knownValues[i]) + .append("}\n"); + } + return ledger.toString(); + } + + /** + * The id to store densely for {@code tagHash} (an Entry's tag hash, or a tag id), or 0 for a + * custom tag, which stays in the buckets. Every known tag is dense, slotted or not, so an + * id-keyed write never reaches the hash path. + */ + private static long denseIdOf(long tagHash) { + // a known tag's hash is its id (upper 32 bits nonzero); a custom tag's upper 32 bits are zero + return (tagHash >>> 32) != 0 ? tagHash : 0L; + } + + /** + * A dense id's occupancy bit. The bit only rules a tag out quickly; the scan decides, so ids may + * share one. No branch for a tag without a slot: a long shift uses only the low 6 bits of its + * count, so {@code NO_SLOT} (0xFFFF) lands on bit 63, which the generator reserves (real slots + * stay below 63) and every unslotted tag shares. + */ + static long slotBit(long denseId) { + return 1L << KnownTagCodec.slotOf(denseId); + } + + /** + * Index of {@code denseId} in the dense arrays, or -1; skips the scan when its slot bit is clear. + */ + private int knownIndexOf(long denseId) { + if ((this.knownOccupancy & slotBit(denseId)) == 0) { + return -1; // definitely absent + } + long[] ids = this.knownIds; + for (int i = 0; i < this.knownCount; ++i) { + if (ids[i] == denseId) return i; + } + return -1; + } + + /** + * Stores a dense value, replacing it in place when present or appending it, and returns the prior + * value, or null when there was none. Dense values are never null. Builds no Entry, so a caller + * that discards the result allocates nothing, whether or not the JIT inlines this. + */ + private Object storeKnownValue(long denseId, Object value) { + int i = this.knownIndexOf(denseId); + if (i >= 0) { + Object prior = this.knownValues[i]; + this.knownValues[i] = value; + return prior; + } + int idx = this.knownCount; + if (idx == this.knownIds.length) { + this.spillKnown(); + } + this.knownIds[idx] = denseId; + this.knownValues[idx] = value; + this.knownCount = idx + 1; + this.knownOccupancy |= slotBit(denseId); + this.knownTraceLevel |= KnownTagCodec.isTraceLevel(denseId); + return null; + } + + /** + * {@code tagId}, when it names a known tag; the checked id-keyed setters reject anything else. + */ + private static long requireKnownId(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw new IllegalArgumentException("not a known tag id: " + Long.toHexString(tagId)); + } + return tagId; + } + + /** + * Makes room for one more dense tag. A map's first dense tag on the shared empty arrays allocates + * its own at {@link #KNOWN_INIT_CAP} -- a plain allocation, since on fresh unhinted spans this + * runs once per span and is compiled inline. A map presized from a {@link SizingHint} skips that + * step. A full map grows in {@link #growKnown}. + */ + private void spillKnown() { + if (this.knownIds.length == 0) { + this.knownIds = new long[KNOWN_INIT_CAP]; + this.knownValues = new Object[KNOWN_INIT_CAP]; + } else { + this.growKnown(); + } + } + + /** + * Grows full dense arrays by doubling, never below one more slot than they hold, and bounded by + * {@link KnownTagCodec#serialLimit()}: a map holds each known tag at most once, so that many + * entries always suffice. Doubling rather than jumping straight to the bound keeps a span that + * outgrows a small {@link SizingHint} seed from paying for a full-bound array; the hint then + * tunes up so later spans of the operation are presized. Rare, so the copying stays out of the + * write. + */ + private void growKnown() { + int length = this.knownIds.length; + int newCap = Math.max(Math.min(length << 1, KnownTagCodec.serialLimit()), length + 1); + this.knownIds = Arrays.copyOf(this.knownIds, newCap); + this.knownValues = Arrays.copyOf(this.knownValues, newCap); + } + + /** Clears a read-through tombstone for a dense tag being set; out of line, as it is rare. */ + private void clearDenseTombstone(long denseId) { + this.removedFromParent.remove(KnownTagCodec.nameOf(denseId)); + } + + /** + * Local dense write: clears any tombstone for the tag, then stores the value, returning the prior + * value or null. The {@code set(long, ...)} methods use this; it builds no Entry. + */ + private Object storeDenseLocal(long denseId, Object value) { + this.checkWriteAccess(); + return this.storeDenseTrusted(denseId, value); + } + + /** {@link #storeDenseLocal} without the frozen check, for trusted writes. */ + private Object storeDenseTrusted(long denseId, Object value) { + if (this.removedFromParent != null) { + this.clearDenseTombstone(denseId); + } + return this.storeKnownValue(denseId, value); + } + + /** {@link #storeDenseLocal}, returning the prior value as an Entry, per the Map contract. */ + private Entry putDenseLocal(long denseId, Object value) { + Object prior = this.storeDenseLocal(denseId, value); + return prior == null ? null : Entry.newAnyEntry(denseId, prior); + } + + /** Removes a dense value (swap with the last), returning it as an Entry, or null. */ + private Entry removeKnown(long denseId) { + int i = this.knownIndexOf(denseId); + if (i < 0) return null; + Object prior = this.knownValues[i]; + int last = --this.knownCount; + this.knownIds[i] = this.knownIds[last]; + this.knownValues[i] = this.knownValues[last]; + this.knownIds[last] = 0L; + this.knownValues[last] = null; + // knownOccupancy is not cleared: another present id may share the slot bit. + return Entry.newAnyEntry(denseId, prior); + } + + /** + * Whether an ancestor's dense value for {@code denseId} (held at {@code fromAncestor}) is visible + * from this leaf: not shadowed by a nearer level's dense value of the same id, and not tombstoned + * by a nearer level. The dense counterpart of {@code parentEntryVisible}; by disjointness no + * bucket check is needed. A nearer level with no trace-level ids cannot shadow a trace-level one. + */ + private boolean parentDenseVisible(long denseId, TagMap fromAncestor) { + boolean traceLevel = KnownTagCodec.isTraceLevel(denseId); + String tag = null; // resolved lazily, only if a nearer level carries tombstones + for (DenseTagMap nearer = this; nearer != fromAncestor; nearer = (DenseTagMap) nearer.parent) { + if ((!traceLevel || nearer.knownTraceLevel) && nearer.knownIndexOf(denseId) >= 0) { + return false; // shadowed by a nearer dense value + } + if (nearer.removedFromParent != null) { + if (tag == null) tag = KnownTagCodec.nameOf(denseId); + if (nearer.removedFromParent.contains(tag)) return false; // tombstoned by a nearer level + } + } + return true; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/HashTagMap.java b/internal-api/src/main/java/datadog/trace/api/HashTagMap.java new file mode 100644 index 00000000000..aef9ad8b459 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashTagMap.java @@ -0,0 +1,20 @@ +package datadog.trace.api; + +/** + * The hash-only {@link TagMap}: every tag, known or custom, is an {@link TagMap.Entry} in the + * buckets. Used when the dense store is off; see {@link TagMapFactory}. + */ +final class HashTagMap extends TagMap { + HashTagMap(TagMap parent) { + super(parent, new Object[1 << 4]); + } + + HashTagMap(Object[] buckets, int size) { + super(buckets, size); + } + + @Override + TagMapFactory factory() { + return HashTagMapFactory.INSTANCE; + } +} 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 c307b38ae88..62a751ecc3f 100644 --- a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java +++ b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java @@ -5,20 +5,26 @@ * the ID layout and namespace naming policy. * *

Datadog and OpenTelemetry names can resolve to the same ID. {@link #canonicalTagName(String)} - * provides the Datadog key used by {@link TagMap}; the namespace readers choose output names. The - * ID does not determine whether a tag is intercepted or where it is stored. + * provides the Datadog key used by {@link TagMap}; the namespace readers choose output names. An ID + * marks a tag that may be intercepted ({@link #INTERCEPTED}), but {@code TagInterceptor} decides + * whether to consume the value. The ID says nothing about where the tag is stored. */ public final class KnownTagCodec { /* - * tagId bit layout: [63-48 serialNum (16 bits, 15 used: bit 63 is kept clear)] [47-32 reserved, - * zero] [31-0 flags]. Keeping bit 63 clear makes every id positive, so 0 (unknown) and the - * negative lookup sentinels below can never be ids; the generator enforces it. 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] - * are RESERVED and always zero here: they are the window the dense tag store uses for its - * co-occurrence slot coordinate, which arrives with that store. 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]. Keeping bit 63 clear makes every id positive, so 0 (unknown) and the negative + * lookup sentinels below can never be ids; the generator enforces it. serialNum is globally + * unique per known tag and is the whole of the tag's identity — nameOf/openTelemetryNameOf key 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), bit 1 is the INTERCEPTED bit, and bit 0 is the + * HAS_SCOPED_ALIAS 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 + * direction, so direction applies when a tag is named, never when it is stored. * *

The INTERCEPTED bit marks a tag TagInterceptor may route on the set-path, to a span field or * a sampling directive instead of tag storage. It is a hint, not a decision: the interceptor's @@ -73,16 +79,42 @@ public static boolean hasScopedAlias(long tagId) { return (tagId & HAS_SCOPED_ALIAS) != 0L; } + /** Bit position of the slot: bits [47-32] of an id. */ + public static final int SLOT_SHIFT = 32; + + /** The slot of a known tag on no modeled span type. */ + public static final int NO_SLOT = 0xFFFF; + + /** + * The tag's slot, or {@link #NO_SLOT}. The generator colors tags that appear together on a span + * type with distinct slots, so a span's slotted tags never collide: {@code 1L << slotOf(id)} is + * an exact presence bit for them. Every tag without a slot shares bit 63, which no real slot + * uses. + */ + public static int slotOf(long tagId) { + return (int) (tagId >>> SLOT_SHIFT) & 0xFFFF; + } + + /** True if the tag has a slot, below {@link #slotCount()}. */ + public static boolean hasSlot(long tagId) { + return slotOf(tagId) != NO_SLOT; + } + + /** Slots in use: every slotted tag's slot is below this. */ + public static int slotCount() { + return KnownTags.SLOT_COUNT; + } + /** Returns the tagId with the {@link #LEVEL_TRACE} flag set. */ public static long traceLevel(long tagId) { return tagId | LEVEL_TRACE; } /** - * Builds a tagId from its {@code serialNum} (globally unique per known tag). The reserved [47-32] - * window and the low 32 bits are zero, so the id is fully determined by the serial — the - * generator emits it as a literal. Inverse of {@link #serialNum}. Intended for the code generator - * and tests. + * 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. */ public static long makeTagId(int serialNum) { return (long) serialNum << 48; diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHint.java b/internal-api/src/main/java/datadog/trace/api/SizingHint.java new file mode 100644 index 00000000000..5189eaaa4c5 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHint.java @@ -0,0 +1,56 @@ +package datadog.trace.api; + +import datadog.trace.util.FlatHashtable; +import javax.annotation.Nullable; + +/** + * Opaque per-operation dense-store sizing hint, and a self-contained {@link FlatHashtable.D1} + * entry: it carries everything the probe compares (the operation name as the {@link + * FlatHashtable.D1.Entry} key, plus the cached hash from {@link FlatHashtable.Entry#hash}) and the + * tuned payload ({@link #size}). Holding key, hash, and value in ONE object behind ONE array slot + * is deliberate — entry publication is a single reference store, so a reader sees {@code null} or a + * complete entry (never a torn one), and the {@code final} identity fields are visible even under + * racy publication (JMM final-field guarantee). That sidesteps the memory-ordering / visibility + * problems parallel key/hash/value arrays would create, no volatile or atomics. + * + *

Opaque to everything outside {@code datadog.trace.api}: its only public member is the lookup, + * {@link #forOperation}. {@link TagMap} reads {@link #size} to size a fresh dense store and writes + * it back (best-effort max, see {@link TagMap#recordSize}) at a terminal point; {@link + * SizingHintTable} mints them. Callers only ever hold the reference. + */ +public final class SizingHint extends FlatHashtable.D1.Entry { + // Payload: the tuned dense-store size. Plain racy int, updated best-effort max (see + // TagMap#recordSize -- not strictly monotonic under concurrent finishes). A stale/lost/lowered + // read only mis-sizes an array (over/under-provision), never corrupts tag data, so no + // synchronization. Not safely published by a racing create either (FlatHashtable class doc): a + // reader may see the default 0, which TagMap treats as "no presize" -- benign. + int size; + + // When true, size is fixed and recordSize won't grow it. For the shared overflow hint (a + // HETEROGENEOUS catch-all for over-budget operations): self-tuning it toward the observed max + // would converge to the max across unlike sharers and over-provision the lean ones. + final boolean capped; + + /** + * A fresh, uncapped hint for {@code operationName}, seeded at {@link SizingHintTable#SEED_SIZE}. + */ + /** + * The hint for spans of {@code operationName}, in the entry-span or child-span lane; null when + * the dense store is off (constant-folded) or there is no operation name. See {@link + * SizingHintTable#hintFor}. + */ + @Nullable + public static SizingHint forOperation(@Nullable CharSequence operationName, boolean entrySpan) { + return SizingHintTable.hintFor(operationName, entrySpan); + } + + SizingHint(String operationName) { + this(operationName, SizingHintTable.SEED_SIZE, false); + } + + SizingHint(String operationName, int seedSize, boolean capped) { + super(operationName); + this.size = seedSize; + this.capped = capped; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java new file mode 100644 index 00000000000..84bb2199a90 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java @@ -0,0 +1,101 @@ +package datadog.trace.api; + +import datadog.trace.util.FlatHashtable; +import javax.annotation.Nullable; + +/** + * Process-wide, self-tuning registry of per-operation {@link SizingHint}s, keyed by operation name. + * Pure static and package-private: the tracer resolves a hint at span build through {@link + * SizingHint#forOperation} and hands it to the span, which sizes its dense {@link TagMap} from it + * and records the actual size back when it finishes — the hint converges to the operation's real + * known-tag high-water mark, so later spans of that operation size correctly. + * + *

Only meaningful with the dense store on ({@link TagMap.DenseStore#ENABLED}); with it off, + * {@link #hintFor} returns {@code null} before touching anything, so the default span path does no + * lookup and the lane tables are never even allocated (they live in a holder class). + * + *

Two lanes, because a span's dense size depends systematically on whether it is an entry + * (local-root) span or not: entry spans carry the trace-metadata / enriching tags and children + * don't, so the same operation name has two different steady-state sizes. {@link #hintFor} picks + * the lane. + * + *

Bounded + racy by design. Each lane is a fixed-capacity {@link FlatHashtable.D1} (never + * resized) so memory is bounded even under unbounded/dynamic operation names; once a lane's + * cardinality budget is spent, further operations share a capped overflow hint. Insertion and the + * best-effort-max size update are lock-free and deliberately racy (the table is used from many + * threads without synchronization): a lost size-counter update lets a lane slightly overshoot its + * budget (still bounded by the backing array, whose full-table insert just fails), and two threads + * minting the same operation at once may leave a shadowed, unreachable duplicate. Either only + * mis-sizes an array (over/under-provision) for a span or two, never corrupts tag data (see {@link + * FlatHashtable} and {@link SizingHint} for the rationale). + * + *

Keyed by the operation name's {@code String} form. In practice every operation name we see is + * a {@code String} or a {@link datadog.trace.bootstrap.instrumentation.api.UTF8BytesString}, whose + * {@code toString()} just returns its backing field — so the {@code toString()} here is O(1) and + * allocation-free on the hot path. A {@code null} operation name gets no hint (the span falls back + * to the generic default capacity); a missed hint is benign, never wrong. + */ +final class SizingHintTable { + private SizingHintTable() {} + + // Seed for a fresh per-operation hint: a small floor that self-tunes up via best-effort-max + // recordSize. Kept small because the hint only grows -- a large seed would lock small spans into + // oversized arrays forever; a span that outgrows it pays one doubling. + static final int SEED_SIZE = 4; + + // Fixed size for the shared over-budget hint: the untuned default, DenseTagMap's initial capacity + // (a compile-time constant, so reading it loads no class). Capped (never grown by recordSize) + // because it's a heterogeneous catch-all -- growing it would over-provision lean sharers to the + // max of an unlike cohort. + static final int OVERFLOW_SEED = DenseTagMap.KNOWN_INIT_CAP; + + // Max distinct operation names per lane that get their own hint before collapsing to the capped + // overflow hint. + static final int CARDINALITY_LIMIT = 512; + + /** + * The lanes, in a holder so they are only allocated once the dense store is on and a span asks + * for a hint. + */ + static final class Lanes { + private Lanes() {} + + // Entry (local-root, enriched) lane and non-entry (child) lane, keyed by operation name. + static final FlatHashtable.D1 ENTRY = + FlatHashtable.D1.createFixed(SizingHint.class, CARDINALITY_LIMIT); + static final FlatHashtable.D1 CHILD = + FlatHashtable.D1.createFixed(SizingHint.class, CARDINALITY_LIMIT); + + // Shared capped hint each lane returns once its budget is exhausted. + static final SizingHint ENTRY_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true); + static final SizingHint CHILD_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true); + } + + /** + * The sizing hint for {@code operationName} in the given lane: the existing one, a freshly-minted + * (seeded) one if the lane has budget, or the shared capped overflow hint if it's full. Returns + * {@code null} when the dense store is off (constant-folded, so the default span path does no + * lookup) and for a {@code null} operation name — the span then uses the generic default capacity + * (operation-less spans aren't reliably similar, so they get no hint). Hits are a single probe; + * the create path is warmup-rare. + */ + @Nullable + static SizingHint hintFor(@Nullable CharSequence operationName, boolean entrySpan) { + if (!TagMap.DenseStore.ENABLED) { + return null; + } + if (operationName == null) { + return null; + } + // O(1) for String / UTF8BytesString (see class doc) + final String key = operationName.toString(); + final FlatHashtable.D1 lane = entrySpan ? Lanes.ENTRY : Lanes.CHILD; + // get() then getOrCreate() would probe twice on a miss; getOrCreate() already returns a hit + // directly, and returns null only when the fixed lane is full and the key is absent. + final SizingHint hint = lane.getOrCreate(key, SizingHint::new); + if (hint != null) { + return hint; + } + return entrySpan ? Lanes.ENTRY_OVERFLOW : Lanes.CHILD_OVERFLOW; + } +} 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 9553fb2a8f2..79388d0707f 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -48,12 +48,12 @@ *

  • adaptive collision * */ -public final class TagMap implements Map, Iterable { +public abstract 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); + // would fail with ArrayIndexOutOfBoundsException, size 1 works). Built by the factory, so it is + // the one TagMap implementation this JVM loads; the constructor reads no TagMap statics. + public static final TagMap EMPTY = TagMapFactory.Default.INSTANCE.newEmpty(); // Sentinel for an ID not yet resolved by the reused EntryReadingHelper. Zero denotes an unknown // tag, so it cannot represent "not computed". @@ -76,11 +76,20 @@ public static final TagMap fromMapImmutable(@Nonnull Map map) { } public static final TagMap create() { - return new TagMap(); + return TagMapFactory.Default.INSTANCE.create(null); } public static final TagMap create(int size) { - return new TagMap(); + return TagMapFactory.Default.INSTANCE.create(null); + } + + /** + * Creates a mutable TagMap whose dense store is sized per-operation from {@code hint} (see {@link + * SizingHintTable}). A {@code null} hint behaves exactly like {@link #create()}; the hash-only + * implementation ignores the hint. + */ + public static final TagMap create(@Nullable SizingHint hint) { + return TagMapFactory.Default.INSTANCE.create(null, hint); } /** @@ -98,6 +107,34 @@ public static final TagMap create(int size) { * local miss/removal for no benefit. */ public static final TagMap createFromParent(TagMap parent) { + return createFromParent(parent, null); + } + + /** + * Read-through variant of {@link #create(SizingHint)}: reads through to {@code parent} on local + * misses AND sizes the LOCAL dense store from {@code hint}. The two are orthogonal — the parent + * supplies inherited reads, while locally-set known tags live in this map's own dense array, + * whose initial capacity the hint tunes. So the common child / shared-parent path (a non-null + * read-through parent) still gets per-operation sizing; without this the local store would fall + * back to the fixed default. A {@code null} hint behaves exactly like {@link + * #createFromParent(TagMap)}. + */ + public static final TagMap createFromParent(TagMap parent, @Nullable SizingHint hint) { + // Calls the JVM's factory directly, a static final constant receiver, rather than through the + // factory-taking overload below: a factory parameter costs the span-creation path an inlining + // level and the constant receiver. + return TagMapFactory.Default.INSTANCE.create(readThroughParent(parent), hint); + } + + /** + * {@link #createFromParent(TagMap, SizingHint)} through {@code factory}; tests pick the store. + */ + static TagMap createFromParent(TagMapFactory factory, TagMap parent, @Nullable SizingHint hint) { + return factory.create(readThroughParent(parent), hint); + } + + /** {@code parent} checked for read-through: it must be frozen, and an empty one is dropped. */ + private static TagMap readThroughParent(TagMap parent) { if (parent != null) { if (!parent.frozen) { throw new IllegalStateException("read-through parent must be frozen"); @@ -107,12 +144,18 @@ public static final TagMap createFromParent(TagMap parent) { // 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 null; } } - return new TagMap(parent); + return parent; } + /** + * The factory for this map's own implementation, so that instance methods that make a new map + * ({@link #copy}) keep its type rather than taking the JVM's default store. + */ + abstract TagMapFactory factory(); + /** Creates a new TagMap.Ledger */ public static final Ledger ledger() { return new Ledger(); @@ -1206,8 +1249,13 @@ public EntryChange next() { * removed from the collision chain. */ - final Object[] buckets; - private int size; + /** Holder for the dense-store flag, read once from Config so the JIT folds it. */ + static final class DenseStore { + static final boolean ENABLED = Config.get().isTraceDenseTagsEnabled(); + } + + Object[] buckets; + int size; private boolean frozen; /** @@ -1229,27 +1277,24 @@ public EntryChange next() { * and rare, so it costs a lazy allocation on removal rather than complicating the hot bucket * code. */ - private Set removedFromParent; - - public TagMap() { - this((TagMap) null); - } + Set removedFromParent; /** * Fresh mutable map that reads through to {@code parent} (may be null). The parent is set here at * construction and never re-pointed (only detached to null by {@link #clear()}), so read-through - * optimizations can treat it as fixed. + * optimizations can treat it as fixed. {@code buckets} is the starting bucket array (a power of + * two in length); a subclass may pass a shared empty one that {@link #writableBuckets} copies. */ - private TagMap(TagMap parent) { + TagMap(TagMap parent, Object[] buckets) { // needs to be a power of 2 for bucket masking calculation to work as intended - this.buckets = new Object[1 << 4]; + this.buckets = buckets; this.size = 0; this.frozen = false; this.parent = parent; } /** Used for inexpensive immutable */ - private TagMap(Object[] buckets, int size) { + TagMap(Object[] buckets, int size) { this.buckets = buckets; this.size = size; this.frozen = true; @@ -1259,8 +1304,9 @@ private TagMap(Object[] buckets, int size) { @Override public int size() { // Exact (Map contract). Under read-through resolves the union; prefer estimateSize() for hints. + int local = this.size + this.knownCount(); // buckets + dense TagMap parent = this.parent; - return parent == null ? this.size : this.size + this.visibleParentCount(); + return parent == null ? local : local + this.visibleParentCount(); } /** @@ -1270,6 +1316,7 @@ public int size() { private int visibleParentCount() { int count = 0; for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + count += this.countDenseVisibleIn(ancestor); Object[] parentBuckets = ancestor.buckets; for (int i = 0; i < parentBuckets.length; ++i) { Object parentBucket = parentBuckets[i]; @@ -1293,7 +1340,7 @@ private int visibleParentCount() { @Override public boolean isEmpty() { // Exact (Map contract). Under read-through resolves the parent; prefer isDefinitelyEmpty(). - if (this.size != 0) { + if (this.size != 0 || this.knownCount() != 0) { return false; } TagMap parent = this.parent; @@ -1307,14 +1354,14 @@ public boolean isEmpty() { // entry -> this map is non-empty. return false; } - // size == 0 with tombstones (rare): empty iff every visible ancestor entry is tombstoned + // no local entries but tombstones (rare): empty iff every visible ancestor entry is tombstoned return this.visibleParentCount() == 0; } public boolean isDefinitelyEmpty() { // Cheap: empty iff no level in the chain holds a local entry (ignores shadowing/tombstones). for (TagMap level = this; level != null; level = level.parent) { - if (level.size != 0) { + if (level.size != 0 || level.knownCount() != 0) { return false; } } @@ -1322,14 +1369,44 @@ public boolean isDefinitelyEmpty() { } public int estimateSize() { - // Upper bound: sum of every level's local size, ignoring read-through shadowing/removals. + // Upper bound: sum of every level's local size (buckets + dense), ignoring read-through + // shadowing/removals. int total = 0; for (TagMap level = this; level != null; level = level.parent) { - total += level.size; + total += level.size + level.knownCount(); } return total; } + /** The number of dense values held locally; 0 for a map without a dense store. */ + int knownCount() { + return 0; + } + + /** + * Feeds this map's final dense-entry count back to {@code hint} (call at a terminal point, e.g. + * serialization), so future maps of the same operation are sized correctly. A no-op for a map + * without a dense store, and for a {@code null} hint. + * + *

    Best-effort max, not a strict monotonic max: the read-compare-write is unsynchronized, so + * two spans of the same operation finishing concurrently can both read the same old size and the + * later write can lower it (a 10-tag span then a 5-tag span settles at 5). And the size is a + * plain field, so a fresh update isn't promptly visible to span-creation threads. Both are + * deliberately tolerated: a stale or lowered hint only mis-sizes a later span's dense array (one + * extra grow-copy), never corrupts tag data — so it stays lock- and atomic-free, and the next + * heavy span of the operation nudges it back up. A CAS/atomic max would buy exactness at the cost + * of contention on this terminal path, which isn't worth it for a sizing hint. + */ + public void recordSize(@Nullable SizingHint hint) {} + + /** + * The number of {@code ancestor}'s dense values visible from this leaf; 0 for a map without a + * dense store. + */ + int countDenseVisibleIn(TagMap ancestor) { + return 0; + } + @Deprecated @Override public Object get(Object tag) { @@ -1468,8 +1545,8 @@ private Entry getEntry(String canonicalTag, long tagHash) { 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) { + /** Looks up an entry in this map's own storage only — no read-through to the parent. */ + Entry getLocalEntry(String tag, long tagHash) { Object[] thisBuckets = this.buckets; int hash = Entry.bucketHash(tagHash); return findInBucket(thisBuckets[hash & (thisBuckets.length - 1)], hash, tag); @@ -1517,6 +1594,11 @@ boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) { return true; } + /** The buckets to write to; a subclass starting on shared buckets copies them here. */ + Object[] writableBuckets() { + return this.buckets; + } + @Deprecated @Override public Object put(@Nonnull String tag, Object value) { @@ -1711,6 +1793,14 @@ public Internals internals() { */ @NoEscape public static final class Internals { + /** + * Whether the store keeps a known tag's value as an object: true for the dense store. A caller + * holding a primitive then boxes it once and passes the box down (to the interceptor and the + * store alike); otherwise the hash store keeps the primitive, and the caller should not box. A + * static final, so the check folds. + */ + public static final boolean STORES_BOXES = DenseStore.ENABLED; + private final TagMap map; Internals(TagMap map) { @@ -1719,39 +1809,87 @@ public static final class Internals { /* * Sets a known tag by id without validating it, unlike TagMap.set(long, ...): the caller has - * already checked KnownTagCodec.isKnown(tagId). + * already checked KnownTagCodec.isKnown(tagId), and the span's map is never frozen, so the write + * skips the frozen check. With the dense store on, the tag goes straight to the dense arrays, so + * no Entry is built. */ public void setKnown(long tagId, @Nonnull Object value) { - this.map.putEntry(new Entry(tagId, null, Entry.ANY, 0L, value)); + this.map.setKnownTrusted(tagId, value); } public void setKnown(long tagId, @Nonnull CharSequence value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.OBJECT, 0L, value)); + this.map.setKnownTrusted(tagId, value); } public void setKnown(long tagId, boolean value) { - this.map.putEntry( - new Entry( - tagId, null, EntryReader.BOOLEAN, Entry.boolean2Prim(value), Boolean.valueOf(value))); + this.map.setKnownTrusted(tagId, value); } public void setKnown(long tagId, int value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.INT, Entry.int2Prim(value), null)); + this.map.setKnownTrusted(tagId, value); } public void setKnown(long tagId, long value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.LONG, Entry.long2Prim(value), null)); + this.map.setKnownTrusted(tagId, value); } public void setKnown(long tagId, float value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.FLOAT, Entry.float2Prim(value), null)); + this.map.setKnownTrusted(tagId, value); } public void setKnown(long tagId, double value) { - this.map.putEntry(new Entry(tagId, null, EntryReader.DOUBLE, Entry.double2Prim(value), null)); + this.map.setKnownTrusted(tagId, value); + } + + /** + * The value of a known tag, boxed if necessary, or null: {@code getEntry(tagId)}'s value, + * including read-through, without building an Entry for a local dense value. + */ + public Object getKnownValue(long tagId) { + return this.map.getKnownValue(tagId); } } + /* + * Trusted, unchecked known-tag writes behind Internals.setKnown: no id validation and no frozen + * check. A subclass with a dense store overrides them to write the dense arrays. + */ + void setKnownTrusted(long tagId, @Nonnull Object value) { + this.putEntryLocal(new Entry(tagId, null, Entry.ANY, 0L, value)); + } + + void setKnownTrusted(long tagId, @Nonnull CharSequence value) { + this.putEntryLocal(new Entry(tagId, null, EntryReader.OBJECT, 0L, value)); + } + + void setKnownTrusted(long tagId, boolean value) { + this.putEntryLocal( + new Entry( + tagId, null, EntryReader.BOOLEAN, Entry.boolean2Prim(value), Boolean.valueOf(value))); + } + + void setKnownTrusted(long tagId, int value) { + this.putEntryLocal(new Entry(tagId, null, EntryReader.INT, Entry.int2Prim(value), null)); + } + + void setKnownTrusted(long tagId, long value) { + this.putEntryLocal(new Entry(tagId, null, EntryReader.LONG, Entry.long2Prim(value), null)); + } + + void setKnownTrusted(long tagId, float value) { + this.putEntryLocal(new Entry(tagId, null, EntryReader.FLOAT, Entry.float2Prim(value), null)); + } + + void setKnownTrusted(long tagId, double value) { + this.putEntryLocal(new Entry(tagId, null, EntryReader.DOUBLE, Entry.double2Prim(value), null)); + } + + /** {@code getEntry(tagId)}'s value, or null; see {@link Internals#getKnownValue}. */ + Object getKnownValue(long tagId) { + Entry entry = this.getEntry(tagId); + return entry == null ? null : entry.objectValue(); + } + public void set(long tagId, @Nonnull Object value) { this.putEntry(Entry.newAnyEntry(tagId, value)); } @@ -1816,14 +1954,18 @@ public Entry getAndSet(@Nullable Entry newEntry) { */ Entry putEntry(@Nonnull Entry newEntry) { this.checkWriteAccess(); + return this.putEntryLocal(newEntry); + } + /** {@link #putEntry} without the frozen check, for trusted writes. */ + private Entry putEntryLocal(@Nonnull 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); } - Object[] thisBuckets = this.buckets; + Object[] thisBuckets = this.writableBuckets(); int newHash = newEntry.hash(); int bucketIndex = newHash & (thisBuckets.length - 1); @@ -1948,15 +2090,20 @@ private void putAllOptimizedMap(TagMap that) { that.forEach(this, (self, entry) -> self.set(entry)); return; } - if (this.size == 0) { + // empty must consider both regions: a map holding only dense values has size == 0 + if (this.size == 0 && this.knownCount() == 0) { this.putAllIntoEmptyMap(that); } else { this.putAllMerge(that); } } - private void putAllMerge(TagMap that) { - Object[] thisBuckets = this.buckets; + void putAllMerge(TagMap that) { + if (that.size == 0) { + return; // no bucket entries to merge; leaves shared empty buckets shared + } + + Object[] thisBuckets = this.writableBuckets(); Object[] thatBuckets = that.buckets; // Since TagMap-s don't support expansion, buckets are perfectly aligned @@ -2072,8 +2219,8 @@ private void putAllMerge(TagMap that) { /* * Specially optimized version of putAll for the common case of destination map being empty */ - private void putAllIntoEmptyMap(TagMap that) { - Object[] thisBuckets = this.buckets; + void putAllIntoEmptyMap(TagMap that) { + Object[] thisBuckets = this.writableBuckets(); Object[] thatBuckets = that.buckets; // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check @@ -2204,8 +2351,8 @@ private Entry getAndRemove(String canonicalTag, long tagHash) { return localRemoved; } - /** Removes an entry from this map's own buckets only — no parent/tombstone handling. */ - private Entry removeLocal(String tag, long tagHash) { + /** Removes an entry from this map's own storage only — no parent/tombstone handling. */ + Entry removeLocal(String tag, long tagHash) { Object[] thisBuckets = this.buckets; int hash = Entry.bucketHash(tagHash); @@ -2246,7 +2393,7 @@ 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); + TagMap copy = this.factory().create(this.parent); copy.putAllIntoEmptyMap(this); if (this.removedFromParent != null) { copy.removedFromParent = new HashSet<>(this.removedFromParent); @@ -2300,6 +2447,7 @@ 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) { + this.forEachDenseVisibleIn(ancestor, consumer); Object[] parentBuckets = ancestor.buckets; for (int i = 0; i < parentBuckets.length; ++i) { Object parentBucket = parentBuckets[i]; @@ -2346,6 +2494,7 @@ public void forEach(T thisObj, BiConsumer con private void forEachParent(T thisObj, BiConsumer consumer) { for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + this.forEachDenseVisibleIn(ancestor, thisObj, consumer); Object[] parentBuckets = ancestor.buckets; for (int i = 0; i < parentBuckets.length; ++i) { Object parentBucket = parentBuckets[i]; @@ -2395,6 +2544,7 @@ public void forEach( private void forEachParent( T thisObj, U otherObj, TriConsumer consumer) { for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + this.forEachDenseVisibleIn(ancestor, thisObj, otherObj, consumer); Object[] parentBuckets = ancestor.buckets; for (int i = 0; i < parentBuckets.length; ++i) { Object parentBucket = parentBuckets[i]; @@ -2418,11 +2568,34 @@ private void forEachParent( } } + /* + * Emit ancestor's dense values visible from this leaf, through a reused flyweight; no-ops for a + * map without a dense store. + */ + void forEachDenseVisibleIn(TagMap ancestor, Consumer consumer) {} + + void forEachDenseVisibleIn( + TagMap ancestor, T thisObj, BiConsumer consumer) {} + + void forEachDenseVisibleIn( + TagMap ancestor, + T thisObj, + U otherObj, + TriConsumer consumer) {} + + /** + * Loads {@code level}'s dense value at {@code index} into {@code reader} if it is visible from + * this leaf ({@code level} is this map or an ancestor), returning whether it was. Only called for + * an index below {@code level.knownCount()}, so never for a map without a dense store. + */ + boolean readDenseIfVisible(TagMap level, int index, EntryReadingHelper reader) { + return false; + } + public void clear() { this.checkWriteAccess(); - Arrays.fill(this.buckets, null); - this.size = 0; + this.clearLocal(); // clear() removes ALL mappings, including any inherited through read-through. Detaching the // parent (rather than tombstoning every inherited key) is simpler and cheaper, and leaves an // empty, parent-less map. Detach is one-way -- the parent is never re-pointed. @@ -2430,6 +2603,12 @@ public void clear() { this.removedFromParent = null; } + /** Empties this map's own storage (not the parent link or tombstones), for {@link #clear()}. */ + void clearLocal() { + Arrays.fill(this.buckets, null); + this.size = 0; + } + public TagMap freeze() { this.frozen = true; @@ -2441,7 +2620,12 @@ public boolean isFrozen() { } public void checkWriteAccess() { - if (this.frozen) throw new IllegalStateException("TagMap frozen"); + if (this.frozen) throw frozenError(); + } + + /** Built out of line, so every write's access check stays a field test and a branch. */ + private static IllegalStateException frozenError() { + return new IllegalStateException("TagMap frozen"); } void checkIntegrity() { @@ -2612,7 +2796,13 @@ abstract static class IteratorBase { private TagMap level; private Object[] buckets; - private Entry nextEntry; + // A bucket entry is its own retain-safe Entry; a dense value is emitted through the reused + // denseReader flyweight, valid only until the next element (call .entry() to retain one). + private EntryReader nextEntry; + private EntryReadingHelper denseReader; + + // dense cursor for the current level; reset when moving to the next ancestor + private int knownIndex = 0; private int bucketIndex = -1; @@ -2632,9 +2822,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; } @@ -2646,9 +2836,9 @@ final Entry nextEntryOrThrowNoSuchElement() { } } - final Entry nextEntryOrNull() { + final EntryReader nextEntryOrNull() { if (this.nextEntry != null) { - Entry nextEntry = this.nextEntry; + EntryReader nextEntry = this.nextEntry; this.nextEntry = null; return nextEntry; } @@ -2656,8 +2846,20 @@ final Entry nextEntryOrNull() { return this.hasNext() ? this.nextEntry : null; } - private final Entry advance() { + private final EntryReader advance() { while (true) { + // the current level's dense values first, then its buckets + if (this.knownIndex < this.level.knownCount()) { + EntryReadingHelper reader = this.denseReader; + if (reader == null) { + reader = this.denseReader = new EntryReadingHelper(); + } + if (this.map.readDenseIfVisible(this.level, this.knownIndex++, reader)) { + return reader; + } + continue; // ancestor dense value shadowed/tombstoned -> skip + } + Entry tagEntry = this.rawAdvance(); if (tagEntry != null) { // leaf entries emit as-is; ancestor entries only if visible from the leaf -- not shadowed @@ -2671,6 +2873,7 @@ private final Entry advance() { // current level exhausted; advance to the next ancestor's buckets (read-through union) if (this.level.parent != null) { this.level = this.level.parent; + this.knownIndex = 0; this.buckets = this.level.buckets; this.bucketIndex = -1; this.group = null; @@ -3210,9 +3413,23 @@ public boolean isEmpty() { @Override public Iterator> iterator() { - @SuppressWarnings({"rawtypes", "unchecked"}) - Iterator> iter = (Iterator) this.map.iterator(); - return iter; + return new EntriesIterator(this.map); + } + } + + /** + * entrySet() yields retain-safe Map.Entry objects: a bucket entry is returned as is, a dense + * value is materialized into a fresh Entry. + */ + static final class EntriesIterator extends IteratorBase + implements Iterator> { + EntriesIterator(TagMap map) { + super(map); + } + + @Override + public Map.Entry next() { + return this.nextEntryOrThrowNoSuchElement().entry(); } } @@ -3308,6 +3525,14 @@ void set(String tag, Object value) { this.tagId = TagMap.TAG_ID_NOT_COMPUTED; // resolve lazily via keyOf on first tagId() access } + /** A dense value: the id is already known, so it needs no lookup. */ + void set(long tagId, Object value) { + this.mapEntry = null; + this.tag = KnownTagCodec.nameOf(tagId); + this.value = value; + this.tagId = tagId; + } + void set(Map.Entry mapEntry) { this.mapEntry = mapEntry; this.tag = mapEntry.getKey(); @@ -3392,7 +3617,10 @@ public Object objectValue() { @Override public TagMap.Entry entry() { - return TagMap.anyEntryFor(this.tag, this.value); + long id = this.tagId; + return id != TagMap.TAG_ID_NOT_COMPUTED && id != 0 + ? TagMap.Entry.newAnyEntry(id, this.value) + : TagMap.anyEntryFor(this.tag, this.value); } @Override diff --git a/internal-api/src/main/java/datadog/trace/api/TagMapFactory.java b/internal-api/src/main/java/datadog/trace/api/TagMapFactory.java new file mode 100644 index 00000000000..5d333731972 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/TagMapFactory.java @@ -0,0 +1,108 @@ +package datadog.trace.api; + +/** + * Builds {@link TagMap}s of the one implementation this JVM uses: {@link DenseTagMap} when the + * dense store is on, {@link HashTagMap} otherwise. Rollout scaffolding, like the earlier + * legacy/optimized split: the hash-only map stays one flag away until the dense store is proven, + * and is then folded away. + * + *

    A class, not an interface, and its implementations are only reached through {@link + * Default#INSTANCE}: the factory not chosen is loaded (to check the field's type) but never linked, + * so its methods are never verified and its map class is never loaded. With one {@code TagMap} + * subclass loaded, class hierarchy analysis devirtualizes every call on a {@code TagMap} without a + * guard. + * + *

    Class initialization must work whichever of TagMap and the factories initializes first: an + * error in a static initializer poisons that class for the JVM's life, and a read of a class still + * initializing on the same thread silently sees null. So this class has no static state + * (initializing a subclass initializes it first, before the subclass's own {@code INSTANCE} is + * set), and {@link TagMap#EMPTY} is built fresh by {@link #newEmpty} rather than read from a + * factory's cache. + */ +abstract class TagMapFactory { + /** The JVM's store, chosen once by the dense-store flag. */ + static final class Default { + static final TagMapFactory INSTANCE = + TagMap.DenseStore.ENABLED ? DenseTagMapFactory.INSTANCE : HashTagMapFactory.INSTANCE; + } + + /** A fresh mutable map that reads through to {@code parent} (may be null). */ + abstract TagMap create(TagMap parent); + + /** + * A fresh mutable map that reads through to {@code parent} (may be null), with its dense store + * presized from {@code hint} (may be null; ignored by the hash-only map). + */ + abstract TagMap create(TagMap parent, SizingHint hint); + + /** A new frozen empty map of this store; {@link TagMap#EMPTY} is built with it. */ + abstract TagMap newEmpty(); + + /** + * The shared frozen empty map: {@link TagMap#EMPTY} for the JVM's store, or one of this store's + * own for the other (only tests build maps of the store the JVM didn't choose). + */ + abstract TagMap empty(); +} + +final class HashTagMapFactory extends TagMapFactory { + static final HashTagMapFactory INSTANCE = new HashTagMapFactory(); + + /** This store's own empty map, when it isn't the JVM's store; see {@link #empty}. */ + static final class OwnEmpty { + static final TagMap EMPTY = INSTANCE.newEmpty(); + } + + private HashTagMapFactory() {} + + @Override + TagMap create(TagMap parent) { + return new HashTagMap(parent); + } + + @Override + TagMap create(TagMap parent, SizingHint hint) { + return new HashTagMap(parent); // no dense store to size + } + + @Override + TagMap newEmpty() { + return new HashTagMap(new Object[1], 0); + } + + @Override + TagMap empty() { + return this == Default.INSTANCE ? TagMap.EMPTY : OwnEmpty.EMPTY; + } +} + +final class DenseTagMapFactory extends TagMapFactory { + static final DenseTagMapFactory INSTANCE = new DenseTagMapFactory(); + + /** This store's own empty map, when it isn't the JVM's store; see {@link #empty}. */ + static final class OwnEmpty { + static final TagMap EMPTY = INSTANCE.newEmpty(); + } + + private DenseTagMapFactory() {} + + @Override + TagMap create(TagMap parent) { + return new DenseTagMap(parent); + } + + @Override + TagMap create(TagMap parent, SizingHint hint) { + return new DenseTagMap(parent, hint); + } + + @Override + TagMap newEmpty() { + return new DenseTagMap(new Object[1], 0); + } + + @Override + TagMap empty() { + return this == Default.INSTANCE ? TagMap.EMPTY : OwnEmpty.EMPTY; + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/DenseTagMapTest.java b/internal-api/src/test/java/datadog/trace/api/DenseTagMapTest.java new file mode 100644 index 00000000000..4eba5e9ae5d --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/DenseTagMapTest.java @@ -0,0 +1,501 @@ +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.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 java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * TagMap's dense store, built through {@link TagMapType#DENSE} so it runs in the normal test JVM + * whatever the dense-store flag says. The shared TagMap suites also run against it; this covers + * what is specific to the dense store. {@link TagMapDenseForkedTest} covers what needs the flag on. + * + *

    A known tag with a dense-store slot is stored densely; a known tag without a slot and a custom + * tag stay in the buckets. Behavior must match the bucket-only store. + */ +class DenseTagMapTest { + // slotted, span-level + static final String COMPONENT = KnownTags.COMPONENT_NAME; + static final String DB_TYPE = KnownTags.DB_TYPE_NAME; + static final String HTTP_ROUTE = KnownTags.HTTP_ROUTE_NAME; + // slotted, trace-level + static final String BASE_SERVICE = KnownTags.DD_BASE_SERVICE_NAME; + // known, no slot + static final String DB_POOL_NAME = KnownTags.DB_POOL_NAME; + static final String UNSLOTTED_KNOWN = KnownTags.RESOURCE_NAME; // on no span type: no slot + // custom + static final String CUSTOM_A = "custom.tag.a"; + static final String CUSTOM_B = "custom.tag.b"; + + /** Two slotted span-level tags that the registry gave the same slot. */ + static long sharedSlotA; + + static long sharedSlotB; + + @BeforeAll + static void checkSetup() throws IllegalAccessException { + assertTrue(KnownTagCodec.hasSlot(KnownTags.COMPONENT_ID)); + assertTrue(KnownTagCodec.hasSlot(KnownTags.DD_BASE_SERVICE_ID)); + assertFalse(KnownTagCodec.hasSlot(KnownTags.RESOURCE_NAME_ID), "resource.name has no slot"); + + Map bySlot = new HashMap<>(); + for (Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) continue; + long id = field.getLong(null); + if (!KnownTagCodec.hasSlot(id) || KnownTagCodec.isTraceLevel(id)) continue; + if (KnownTagCodec.keyOf(KnownTagCodec.nameOf(id)) != id) continue; // skip shared names + Long other = bySlot.putIfAbsent(KnownTagCodec.slotOf(id), id); + if (other != null) { + sharedSlotA = other; + sharedSlotB = id; + return; + } + } + throw new AssertionError("no two slotted tags share a slot"); + } + + private static int knownCount(TagMap map) { + try { + Field f = DenseTagMap.class.getDeclaredField("knownCount"); + f.setAccessible(true); + return f.getInt(map); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + /** A field of DenseTagMap, or of TagMap for the shared bucket state. */ + private static Object field(Object target, String name) { + for (Class c = DenseTagMap.class; c != null; c = c.getSuperclass()) { + try { + Field f = c.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } catch (NoSuchFieldException e) { + // look in the superclass + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + throw new AssertionError("no field " + name); + } + + @Test + void aSlottedKnownTagIsStoredDenselyAndRoundTrips() { + TagMap map = TagMapType.DENSE.create(); + map.set(COMPONENT, "spring-web"); + map.set(BASE_SERVICE, "billing"); + + assertEquals(2, knownCount(map)); + assertEquals("spring-web", map.getObject(COMPONENT)); + assertEquals("billing", map.getString(BASE_SERVICE)); + assertEquals("spring-web", map.getEntry(COMPONENT).objectValue()); + assertEquals(KnownTags.COMPONENT_ID, map.getEntry(COMPONENT).tagId()); + assertTrue(map.containsKey(COMPONENT)); + assertEquals(2, map.size()); + map.checkIntegrity(); + } + + @Test + void customTagsStayInBucketsAndUnslottedKnownTagsAreDense() { + TagMap map = TagMapType.DENSE.create(); + map.set(CUSTOM_A, "alpha"); + map.set(UNSLOTTED_KNOWN, "pool-1"); // a known tag with no slot + + assertEquals(1, knownCount(map)); + assertEquals("alpha", map.getObject(CUSTOM_A)); + assertEquals("pool-1", map.getObject(UNSLOTTED_KNOWN)); + assertEquals(2, map.size()); + map.checkIntegrity(); + } + + @Test + void typedValuesRoundTrip() { + TagMap map = TagMapType.DENSE.create(); + map.set(KnownTags.HTTP_STATUS_CODE_NAME, 404); + map.set(KnownTags.HTTP_RESEND_COUNT_NAME, 3L); + map.set(KnownTags.DD_PROFILING_ENABLED_NAME, true); + map.set(KnownTags.HTTP_ROUTE_ID, (CharSequence) "/users/{id}"); + + assertEquals(404, map.getInt(KnownTags.HTTP_STATUS_CODE_NAME)); + assertEquals(3L, map.getLong(KnownTags.HTTP_RESEND_COUNT_NAME)); + assertTrue(map.getBoolean(KnownTags.DD_PROFILING_ENABLED_NAME)); + assertEquals("/users/{id}", map.getString(HTTP_ROUTE)); + assertEquals(4, knownCount(map)); + map.checkIntegrity(); + } + + @Test + void idKeyedSetGetAndRemove() { + TagMap map = TagMapType.DENSE.create(); + map.set(KnownTags.DB_TYPE_ID, "postgresql"); + + assertEquals("postgresql", map.getEntry(KnownTags.DB_TYPE_ID).objectValue()); + assertEquals("postgresql", map.getObject(DB_TYPE)); + assertEquals("postgresql", map.getAndRemove(KnownTags.DB_TYPE_ID).objectValue()); + assertNull(map.getEntry(KnownTags.DB_TYPE_ID)); + assertEquals(0, map.size()); + map.checkIntegrity(); + } + + @Test + void anOpenTelemetryNameLandsOnTheSameDenseValue() { + TagMap map = TagMapType.DENSE.create(); + map.set(KnownTags.HTTP_METHOD_OTEL_NAME, "GET"); + + assertEquals(1, knownCount(map)); + assertEquals("GET", map.getObject(KnownTags.HTTP_METHOD_NAME)); + assertEquals("GET", map.getObject(KnownTags.HTTP_METHOD_OTEL_NAME)); + map.set(KnownTags.HTTP_METHOD_NAME, "POST"); + assertEquals(1, map.size()); + assertEquals("POST", map.getObject(KnownTags.HTTP_METHOD_OTEL_NAME)); + } + + @Test + void twoTagsSharingASlotInOneMapBothWork() { + assertEquals(KnownTagCodec.slotOf(sharedSlotA), KnownTagCodec.slotOf(sharedSlotB)); + TagMap map = TagMapType.DENSE.create(); + map.set(sharedSlotA, "a"); + map.set(sharedSlotB, "b"); + + assertEquals(2, knownCount(map)); + assertEquals("a", map.getEntry(sharedSlotA).objectValue()); + assertEquals("b", map.getEntry(sharedSlotB).objectValue()); + + map.set(sharedSlotA, "a2"); + assertEquals(2, map.size()); + assertEquals("a2", map.getEntry(sharedSlotA).objectValue()); + + assertEquals("a2", map.getAndRemove(sharedSlotA).objectValue()); + assertNull(map.getEntry(sharedSlotA)); + assertEquals("b", map.getEntry(sharedSlotB).objectValue()); + map.checkIntegrity(); + } + + @Test + void overwriteReplacesInPlaceAndGetAndSetReturnsThePrior() { + TagMap map = TagMapType.DENSE.create(); + map.set(COMPONENT, "first"); + TagMap.Entry prior = map.getAndSet(COMPONENT, "second"); + + assertEquals("first", prior.objectValue()); + assertEquals("second", map.getObject(COMPONENT)); + assertEquals(1, map.size()); + map.checkIntegrity(); + } + + @Test + void removeClearsADenseValue() { + TagMap map = TagMapType.DENSE.create(); + map.set(COMPONENT, "web"); + map.set(CUSTOM_A, "alpha"); + + assertEquals("web", map.remove((Object) COMPONENT)); + assertNull(map.getObject(COMPONENT)); + assertFalse(map.remove(COMPONENT)); + assertEquals(1, map.size()); + map.checkIntegrity(); + } + + @Test + void forEachIteratorAndEntrySetEmitBothRegions() { + TagMap map = TagMapType.DENSE.create(); + map.set(COMPONENT, "web"); + map.set(BASE_SERVICE, "billing"); + map.set(CUSTOM_A, "alpha"); + map.set(DB_POOL_NAME, "pool-1"); + + Map viaForEach = new HashMap<>(); + map.forEach(reader -> viaForEach.put(reader.tag(), reader.objectValue())); + Map viaBiForEach = new HashMap<>(); + map.forEach(viaBiForEach, (m, reader) -> m.put(reader.tag(), reader.objectValue())); + Map viaIterator = new HashMap<>(); + for (TagMap.EntryReader reader : map) { + viaIterator.put(reader.tag(), reader.objectValue()); + } + Map viaEntrySet = new HashMap<>(map); + Map viaFill = new HashMap<>(); + map.fillMap(viaFill); + + assertEquals(4, viaForEach.size()); + assertEquals("web", viaForEach.get(COMPONENT)); + assertEquals(viaForEach, viaBiForEach); + assertEquals(viaForEach, viaIterator); + assertEquals(viaForEach, viaEntrySet); + assertEquals(viaForEach, viaFill); + assertEquals(4, map.keySet().size()); + assertTrue(map.values().contains("billing")); + } + + @Test + void entrySetEntriesAndEntryCopiesAreRetainSafe() { + TagMap map = TagMapType.DENSE.create(); + map.set(COMPONENT, "web"); + map.set(DB_TYPE, "h2"); + + List> entries = new ArrayList<>(map.entrySet()); + List copies = new ArrayList<>(); + for (TagMap.EntryReader reader : map) { + copies.add(reader.entry()); + } + + Map fromEntries = new HashMap<>(); + for (Map.Entry e : entries) fromEntries.put(e.getKey(), e.getValue()); + Map fromCopies = new HashMap<>(); + for (TagMap.Entry e : copies) fromCopies.put(e.tag(), e.objectValue()); + + assertEquals(2, fromEntries.size()); + assertEquals("web", fromEntries.get(COMPONENT)); + assertEquals("h2", fromEntries.get(DB_TYPE)); + assertEquals(fromEntries, fromCopies); + } + + @Test + void anAllDenseMapAllocatesNoBuckets() { + TagMap map = TagMapType.DENSE.create(); + map.set(COMPONENT, "web"); + map.set(DB_TYPE, "h2"); + Object emptyBuckets = field(null, "EMPTY_BUCKETS"); + + assertSame(emptyBuckets, field(map, "buckets")); + TagMap copy = map.copy(); + assertSame(emptyBuckets, field(copy, "buckets")); + + map.set(CUSTOM_A, "alpha"); + assertNotEquals(emptyBuckets, field(map, "buckets")); + for (Object bucket : (Object[]) emptyBuckets) { + assertNull(bucket, "the shared empty buckets were written"); + } + map.clear(); + assertSame(emptyBuckets, field(map, "buckets")); + } + + @Test + void copyIsIndependent() { + TagMap map = TagMapType.DENSE.create(); + map.set(COMPONENT, "web"); + map.set(CUSTOM_A, "alpha"); + + TagMap copy = map.copy(); + copy.set(COMPONENT, "grpc"); + copy.remove(CUSTOM_A); + + assertEquals("web", map.getObject(COMPONENT)); + assertEquals("alpha", map.getObject(CUSTOM_A)); + assertEquals("grpc", copy.getObject(COMPONENT)); + assertEquals(1, copy.size()); + map.checkIntegrity(); + copy.checkIntegrity(); + } + + @Test + void clearEmptiesTheDenseStore() { + TagMap map = TagMapType.DENSE.create(); + map.set(COMPONENT, "web"); + map.set(CUSTOM_A, "alpha"); + map.clear(); + + assertEquals(0, map.size()); + assertTrue(map.isEmpty()); + assertTrue(map.isDefinitelyEmpty()); + assertNull(map.getObject(COMPONENT)); + map.set(COMPONENT, "again"); + assertEquals("again", map.getObject(COMPONENT)); + map.checkIntegrity(); + } + + @Test + void putAllMergesTheDenseStore() { + TagMap src = TagMapType.DENSE.create(); + src.set(BASE_SERVICE, "billing"); + src.set(DB_TYPE, "h2"); + src.set(CUSTOM_A, "alpha"); + + TagMap dst = TagMapType.DENSE.create(); + dst.set(COMPONENT, "web"); + dst.set(BASE_SERVICE, "old"); + dst.putAll(src); + + assertEquals("billing", dst.getObject(BASE_SERVICE)); + assertEquals("h2", dst.getObject(DB_TYPE)); + assertEquals("web", dst.getObject(COMPONENT)); + assertEquals("alpha", dst.getObject(CUSTOM_A)); + assertEquals(4, dst.size()); + dst.checkIntegrity(); + + TagMap empty = TagMapType.DENSE.create(); + empty.putAll(src); + assertEquals(3, empty.size()); + assertEquals("h2", empty.getObject(DB_TYPE)); + empty.checkIntegrity(); + } + + @Test + void aFrozenDenseMapRejectsWrites() { + TagMap map = TagMapType.DENSE.create(); + map.set(COMPONENT, "web"); + TagMap frozen = map.immutableCopy(); + + assertThrows(IllegalStateException.class, () -> frozen.set(COMPONENT, "x")); + assertThrows(IllegalStateException.class, () -> frozen.set(KnownTags.DB_TYPE_ID, "x")); + assertThrows(IllegalStateException.class, () -> frozen.remove(COMPONENT)); + assertEquals("web", frozen.getObject(COMPONENT)); + } + + // ---- read-through: dense values in a frozen parent ---- + + private static TagMap frozenParent() { + TagMap parent = TagMapType.DENSE.create(); + parent.set(BASE_SERVICE, "billing"); // dense, trace-level + parent.set(COMPONENT, "web"); // dense + parent.set(CUSTOM_A, "alpha"); // bucket + return parent.freeze(); + } + + @Test + void aChildReadsThroughToItsParentsDenseValues() { + TagMap child = TagMapType.DENSE.createFromParent(frozenParent()); + child.set(DB_TYPE, "h2"); + child.set(CUSTOM_B, "beta"); + + assertEquals("billing", child.getObject(BASE_SERVICE)); + assertEquals("web", child.getObject(COMPONENT)); + assertEquals("alpha", child.getObject(CUSTOM_A)); + assertEquals("h2", child.getObject(DB_TYPE)); + assertEquals(5, child.size()); + assertEquals(5, child.estimateSize()); + + Map viaForEach = new HashMap<>(); + child.forEach(reader -> viaForEach.put(reader.tag(), reader.objectValue())); + Map viaIterator = new HashMap<>(); + for (TagMap.EntryReader reader : child) viaIterator.put(reader.tag(), reader.objectValue()); + Map viaFill = new HashMap<>(); + child.fillMap(viaFill); + + assertEquals(5, viaForEach.size()); + assertEquals(viaForEach, viaIterator); + assertEquals(viaForEach, viaFill); + child.checkIntegrity(); + } + + @Test + void aChildsDenseValueShadowsItsParents() { + TagMap child = TagMapType.DENSE.createFromParent(frozenParent()); + child.set(BASE_SERVICE, "shipping"); + child.set(COMPONENT, "grpc"); + + assertEquals("shipping", child.getObject(BASE_SERVICE)); + assertEquals("grpc", child.getObject(COMPONENT)); + assertEquals(3, child.size()); + + Map union = new HashMap<>(); + child.forEach(reader -> union.put(reader.tag(), reader.objectValue())); + assertEquals(3, union.size()); + assertEquals("shipping", union.get(BASE_SERVICE)); + assertEquals("grpc", union.get(COMPONENT)); + + List iterated = new ArrayList<>(); + for (TagMap.EntryReader reader : child) iterated.add(reader.tag()); + assertEquals(3, iterated.size()); + } + + @Test + void removingAParentsDenseValueTombstonesIt() { + TagMap child = TagMapType.DENSE.createFromParent(frozenParent()); + + TagMap.Entry removed = child.getAndRemove(BASE_SERVICE); + assertNotNull(removed); + assertEquals("billing", removed.objectValue()); + assertNull(child.getObject(BASE_SERVICE)); + assertEquals("web", child.getObject(COMPONENT)); + assertEquals(2, child.size()); + + Map union = new HashMap<>(); + child.forEach(reader -> union.put(reader.tag(), reader.objectValue())); + assertEquals(2, union.size()); + assertFalse(union.containsKey(BASE_SERVICE)); + + child.set(BASE_SERVICE, "restored"); // re-setting clears the tombstone + assertEquals("restored", child.getObject(BASE_SERVICE)); + assertEquals(3, child.size()); + child.checkIntegrity(); + } + + @Test + void getAndSetReturnsAParentsDenseValueAsThePrior() { + TagMap child = TagMapType.DENSE.createFromParent(frozenParent()); + + TagMap.Entry prior = child.getAndSet(COMPONENT, "grpc"); + assertNotNull(prior); + assertEquals("web", prior.objectValue()); + assertEquals("grpc", child.getObject(COMPONENT)); + } + + @Test + void putAllFromAReadThroughSourceCopiesTheUnion() { + TagMap child = TagMapType.DENSE.createFromParent(frozenParent()); + child.set(DB_TYPE, "h2"); + + TagMap dst = TagMapType.DENSE.create(); + dst.putAll(child); + + assertEquals(4, dst.size()); + assertEquals("billing", dst.getObject(BASE_SERVICE)); + assertEquals("h2", dst.getObject(DB_TYPE)); + assertEquals("alpha", dst.getObject(CUSTOM_A)); + dst.checkIntegrity(); + } + + @Test + void aMultiLevelChainResolvesNearestFirst() { + TagMap middle = TagMapType.DENSE.createFromParent(frozenParent()); + middle.set(COMPONENT, "middle"); + middle.remove(BASE_SERVICE); + TagMap leaf = TagMapType.DENSE.createFromParent(middle.freeze()); + leaf.set(DB_TYPE, "leaf"); + + assertEquals("middle", leaf.getObject(COMPONENT)); + assertNull(leaf.getObject(BASE_SERVICE)); + assertEquals("leaf", leaf.getObject(DB_TYPE)); + assertEquals(3, leaf.size()); + + Map union = new HashMap<>(); + leaf.forEach(reader -> union.put(reader.tag(), reader.objectValue())); + assertEquals(3, union.size()); + assertEquals("middle", union.get(COMPONENT)); + assertFalse(union.containsKey(BASE_SERVICE)); + } + + @Test + void theCoresKnownValueReadMatchesGetEntry() { + TagMap child = TagMapType.DENSE.createFromParent(frozenParent()); + child.set(HTTP_ROUTE, "/users/{id}"); // dense + child.set(COMPONENT, "child"); // dense, shadows the parent's + child.remove(BASE_SERVICE); // tombstones the parent's + + // local hit, no Entry built + assertEquals("/users/{id}", child.internals().getKnownValue(KnownTags.HTTP_ROUTE_ID)); + assertEquals("child", child.internals().getKnownValue(KnownTags.COMPONENT_ID)); + // read-through parent hit, then a tombstoned one and a plain miss + TagMap reader = TagMapType.DENSE.createFromParent(frozenParent()); + assertEquals("web", reader.internals().getKnownValue(KnownTags.COMPONENT_ID)); + assertEquals("billing", reader.internals().getKnownValue(KnownTags.DD_BASE_SERVICE_ID)); + assertNull(child.internals().getKnownValue(KnownTags.DD_BASE_SERVICE_ID)); + assertNull(child.internals().getKnownValue(KnownTags.DB_TYPE_ID)); + // a known tag without a slot, held in the dense store all the same + child.set(UNSLOTTED_KNOWN, "GET /users"); + assertEquals("GET /users", child.internals().getKnownValue(KnownTags.RESOURCE_NAME_ID)); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java b/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java new file mode 100644 index 00000000000..2f3e099b384 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagCodecSlotTest.java @@ -0,0 +1,76 @@ +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 java.lang.reflect.Field; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** {@link KnownTagCodec}'s slot: bits [47-32] of an id, assigned by graph coloring. */ +class KnownTagCodecSlotTest { + @Test + void everySlotIsBelowTheSlotCount() throws IllegalAccessException { + for (Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) { + continue; + } + long tagId = field.getLong(null); + if (KnownTagCodec.hasSlot(tagId)) { + assertTrue(KnownTagCodec.slotOf(tagId) < KnownTagCodec.slotCount(), field.getName()); + } else { + assertEquals(KnownTagCodec.NO_SLOT, KnownTagCodec.slotOf(tagId), field.getName()); + } + } + } + + @Test + void tagsOnAnHttpServerSpanHaveDistinctSlots() { + long[] httpServerTags = { + KnownTags.SERVICE_ID, + KnownTags.COMPONENT_ID, + KnownTags.SPAN_KIND_ID, + KnownTags.HTTP_METHOD_ID, + KnownTags.HTTP_URL_ID, + KnownTags.HTTP_STATUS_CODE_ID, + KnownTags.HTTP_ROUTE_ID, + KnownTags.HTTP_HOSTNAME_ID, + }; + Set slots = new HashSet<>(); + for (long tagId : httpServerTags) { + assertTrue(KnownTagCodec.hasSlot(tagId), Long.toHexString(tagId)); + assertTrue(slots.add(KnownTagCodec.slotOf(tagId)), Long.toHexString(tagId)); + } + } + + @Test + void anOptionalTagOnASpanTypeHasASlot() { + assertTrue(KnownTagCodec.hasSlot(KnownTags.PEER_PORT_ID)); + assertTrue(KnownTagCodec.hasSlot(KnownTags.DB_POOL_NAME_ID)); + } + + @Test + void aTagOnNoSpanTypeHasNoSlot() { + // the tracer overlay's routing-only keys appear on no modeled span type + assertFalse(KnownTagCodec.hasSlot(KnownTags.RESOURCE_NAME_ID)); + } + + @Test + void theSlotDoesNotChangeTheSerialOrFlags() { + long tagId = KnownTags.HTTP_METHOD_ID; + + assertTrue(KnownTagCodec.hasSlot(tagId)); + assertEquals(KnownTags.HTTP_METHOD_NAME, KnownTagCodec.nameOf(tagId)); + assertTrue(KnownTagCodec.isIntercepted(tagId)); + assertFalse(KnownTagCodec.isTraceLevel(tagId)); + } + + @Test + void anUnslottedTagSharesTheReservedTopBit() { + // DenseTagMap.slotBit relies on the long shift masking NO_SLOT (0xFFFF) down to 63 + assertEquals(1L << 63, DenseTagMap.slotBit(KnownTags.RESOURCE_NAME_ID)); + assertTrue(KnownTagCodec.slotCount() < 63, "real slots must stay below the shared bit 63"); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java new file mode 100644 index 00000000000..541f4bca8c0 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java @@ -0,0 +1,236 @@ +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.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Registry semantics for {@link SizingHintTable}, and how {@link DenseTagMap} sizes from and tunes + * a {@link SizingHint}. Hints are only minted with the dense store on, and the flag is read once + * into a static final, so this runs in its own JVM ({@code ForkedTest}) with the flag set first. + * + *

    The table is process-wide static state; each test uses distinct operation names so tests don't + * interfere. The overflow test fills the child lane, so no other test here uses that lane expecting + * a fresh hint. + */ +class SizingHintTableForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + /** Distinct known tag ids that each add one dense entry, in a stable order. */ + static final List KNOWN_IDS = new ArrayList<>(); + + @BeforeAll + static void checkSetup() throws IllegalAccessException { + assertTrue(TagMap.DenseStore.ENABLED, "dense store must be on in this JVM"); + Set ids = new LinkedHashSet<>(); + for (Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) continue; + long id = field.getLong(null); + if (!KnownTagCodec.isKnown(id)) continue; + String name = KnownTagCodec.nameOf(id); + if (name == null || KnownTagCodec.keyOf(name) != id) continue; // skip shared names + ids.add(id); + } + // keep only ids that really add a dense entry (no relabeling of another id) + TagMap probe = TagMap.create(); + for (long id : ids) { + int before = knownCount(probe); + probe.set(id, "v"); + if (knownCount(probe) == before + 1) { + KNOWN_IDS.add(id); + } + } + assertTrue(KNOWN_IDS.size() > 24, "need enough known tags to exercise growth"); + } + + private static Object field(TagMap map, String name) { + try { + Field f = DenseTagMap.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(map); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private static int knownCount(TagMap map) { + return (Integer) field(map, "knownCount"); + } + + private static int knownCapacity(TagMap map) { + long[] ids = (long[]) field(map, "knownIds"); + Object[] values = (Object[]) field(map, "knownValues"); + assertEquals(ids.length, values.length, "dense arrays stay parallel"); + return ids.length; + } + + /** Adds the first {@code n} known tags. */ + private static void fill(TagMap map, int n) { + for (int i = 0; i < n; ++i) { + map.set(KNOWN_IDS.get(i), "v" + i); + } + assertEquals(n, knownCount(map)); + } + + @Test + void nullOperationNameGetsNoHint() { + assertNull(SizingHintTable.hintFor(null, true)); + assertNull(SizingHintTable.hintFor(null, false)); + } + + @Test + void nonStringCharSequenceIsKeyedByContent() { + // Operation names are commonly UTF8BytesString, not String; a content-equal name must resolve + // to the same hint as its String form (keyed by toString(), which is O(1) for these types). + SizingHint viaString = SizingHintTable.hintFor("registry.utf8", true); + CharSequence utf8 = UTF8BytesString.create("registry.utf8"); + assertNotNull(viaString); + assertSame(viaString, SizingHintTable.hintFor(utf8, true)); + } + + @Test + void freshHintIsSeededAndUncapped() { + SizingHint hint = SizingHintTable.hintFor("registry.fresh", true); + assertNotNull(hint); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + assertFalse(hint.capped); + assertEquals("registry.fresh", hint.key()); + } + + @Test + void sameOperationAndLaneReturnsSameInstance() { + SizingHint a = SizingHintTable.hintFor("registry.stable", true); + SizingHint b = SizingHintTable.hintFor("registry.stable", true); + assertSame(a, b); + } + + @Test + void entryAndChildLanesAreIndependent() { + SizingHint entry = SizingHintTable.hintFor("registry.twolane", true); + SizingHint child = SizingHintTable.hintFor("registry.twolane", false); + assertNotNull(entry); + assertNotNull(child); + assertNotSame(entry, child, "each lane holds its own hint for the same operation name"); + // ...and each lane is internally stable. + assertSame(entry, SizingHintTable.hintFor("registry.twolane", true)); + assertSame(child, SizingHintTable.hintFor("registry.twolane", false)); + } + + @Test + void beyondCardinalityBudgetSharesACappedOverflowHint() { + // A hint minted before the lane fills keeps resolving to itself afterwards. + SizingHint early = SizingHintTable.hintFor("registry.flood.early", false); + assertFalse(early.capped); + + // Fill the child lane past its budget; a capped shared hint must appear. + SizingHint firstOverflow = null; + for (int i = 0; i <= SizingHintTable.CARDINALITY_LIMIT && firstOverflow == null; i++) { + SizingHint hint = SizingHintTable.hintFor("registry.flood." + i, false); + assertNotNull(hint); + if (hint.capped) { + firstOverflow = hint; + } + } + assertNotNull(firstOverflow, "lane collapses to a capped overflow hint once full"); + assertEquals(SizingHintTable.OVERFLOW_SEED, firstOverflow.size); + + // Every further over-budget name shares that same capped instance... + SizingHint another = SizingHintTable.hintFor("registry.flood.after", false); + assertTrue(another.capped); + assertSame(firstOverflow, another); + // ...existing hints still resolve (the cap blocks only creation)... + assertSame(early, SizingHintTable.hintFor("registry.flood.early", false)); + // ...and the entry lane has its own budget. + assertFalse(SizingHintTable.hintFor("registry.flood.after", true).capped); + + // A capped hint is never tuned: a heavy span sharing it does not grow it. + TagMap heavy = TagMap.create(another); + fill(heavy, SizingHintTable.OVERFLOW_SEED + 4); + heavy.recordSize(another); + assertEquals(SizingHintTable.OVERFLOW_SEED, another.size); + } + + @Test + void sizingHintPresizesTheDenseStore() { + SizingHint hint = SizingHintTable.hintFor("registry.presize", true); + TagMap map = TagMap.create(hint); + assertSame(DenseTagMap.class, map.getClass()); + assertEquals(hint.size, knownCapacity(map), "presized at the hint's size, before any write"); + + TagMap child = TagMap.createFromParent(TagMap.create().freeze(), hint); + assertEquals(hint.size, knownCapacity(child)); + + // Without a hint, a map starts on the shared empty arrays. + assertEquals(0, knownCapacity(TagMap.create((SizingHint) null))); + assertEquals(0, knownCapacity(TagMap.create())); + } + + @Test + void sizingHintFeedsAndTunesTheDenseStore() { + SizingHint hint = SizingHintTable.hintFor("registry.tuning", true); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + + int observed = SizingHintTable.SEED_SIZE + 3; + TagMap map = TagMap.create(hint); + fill(map, observed); + map.recordSize(hint); + assertEquals(observed, hint.size, "hint self-tunes up to the observed known-tag high-water"); + + // The next map of the operation is presized to fit, so it never grows. + TagMap next = TagMap.create(hint); + assertEquals(observed, knownCapacity(next)); + fill(next, observed); + assertEquals(observed, knownCapacity(next)); + + // A smaller later observation does not shrink the hint. + TagMap smaller = TagMap.create(hint); + fill(smaller, 1); + smaller.recordSize(hint); + assertEquals(observed, hint.size, "recordSize never shrinks the hint"); + + // A null hint is a no-op. + smaller.recordSize(null); + } + + @Test + void fullDenseArraysDoubleUpToTheSerialLimit() { + // Presized small: 4 -> 8 -> 16 + SizingHint hint = new SizingHint("registry.growth"); + assertEquals(4, hint.size); + TagMap map = TagMap.create(hint); + fill(map, 4); + assertEquals(4, knownCapacity(map)); + fill(map, 5); + assertEquals(8, knownCapacity(map)); + fill(map, 9); + assertEquals(16, knownCapacity(map)); + + // Unhinted: first allocation at the default 12, then 24 + TagMap unhinted = TagMap.create(); + fill(unhinted, 1); + assertEquals(12, knownCapacity(unhinted)); + fill(unhinted, 13); + assertEquals(24, knownCapacity(unhinted)); + + // Growth never passes the serial limit, yet always makes room. + TagMap all = TagMap.create(new SizingHint("registry.growth.all")); + fill(all, KNOWN_IDS.size()); + assertTrue(knownCapacity(all) <= KnownTagCodec.serialLimit()); + assertTrue(knownCapacity(all) >= KNOWN_IDS.size()); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java new file mode 100644 index 00000000000..b1134c413a6 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java @@ -0,0 +1,43 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; + +/** + * With the dense store off, {@link SizingHintTable} hands out no hints, never allocates its lanes, + * and the hash-only map ignores a hint. Runs in its own JVM so the flag is read as off. + */ +class SizingHintTableHashOnlyForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "false"); + } + + @Test + void noHintWhenTheDenseStoreIsOff() { + assertFalse(TagMap.DenseStore.ENABLED); + assertNull(SizingHintTable.hintFor("registry.off", true)); + assertNull(SizingHintTable.hintFor("registry.off", false)); + assertNull(SizingHintTable.hintFor(null, true)); + assertFalse(TagMapImplementations.isLoaded("datadog.trace.api.SizingHintTable$Lanes")); + } + + @Test + void theHashOnlyMapIgnoresAHint() { + SizingHint hint = new SizingHint("registry.off.direct"); + TagMap map = TagMap.create(hint); + map.set(KnownTags.COMPONENT_ID, "okhttp"); + map.set(KnownTags.DD_BASE_SERVICE_ID, "svc"); + map.recordSize(hint); + + assertSame(HashTagMap.class, map.getClass()); + assertSame( + HashTagMap.class, TagMap.createFromParent(map.freeze(), hint).getClass(), "read-through"); + assertEquals( + SizingHintTable.SEED_SIZE, hint.size, "recordSize is a no-op without a dense store"); + assertFalse(TagMapImplementations.isLoaded("datadog.trace.api.DenseTagMap")); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapDenseForkedTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapDenseForkedTest.java new file mode 100644 index 00000000000..df27c220972 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapDenseForkedTest.java @@ -0,0 +1,56 @@ +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.Assertions.assertThrows; + +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +/** + * TagMap with the dense-store flag on: what only the flag decides. The flag is read once into a + * static final, so this runs in its own JVM ({@code ForkedTest}) with the flag set before TagMap + * first checks it. The dense store's behavior is covered by {@link DenseTagMapTest} and the shared + * suites, which build through {@link TagMapType#DENSE}. + */ +class TagMapDenseForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + // slotted, span-level + static final String COMPONENT = KnownTags.COMPONENT_NAME; + // custom + static final String CUSTOM_A = "custom.tag.a"; + + private static int knownCount(TagMap map) { + try { + Field f = DenseTagMap.class.getDeclaredField("knownCount"); + f.setAccessible(true); + return f.getInt(map); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + @Test + void onlyTheDenseImplementationIsLoaded() { + TagMap map = TagMap.create(); + map.set(KnownTags.COMPONENT_ID, "okhttp"); + + assertSame(DenseTagMap.class, map.getClass()); + assertSame(DenseTagMap.class, TagMap.EMPTY.getClass()); + assertFalse(TagMapImplementations.isLoaded("datadog.trace.api.HashTagMap")); + } + + @Test + void aLedgerBuildsADenseMap() { + TagMap map = TagMap.ledger().set(COMPONENT, "web").set(CUSTOM_A, "alpha").buildImmutable(); + + assertEquals(1, knownCount(map)); + assertEquals("web", map.getObject(COMPONENT)); + assertEquals("alpha", map.getObject(CUSTOM_A)); + assertThrows(IllegalStateException.class, () -> map.set(COMPONENT, "x")); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapDenseFuzzForkedTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapDenseFuzzForkedTest.java new file mode 100644 index 00000000000..cfcb5f9ff39 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapDenseFuzzForkedTest.java @@ -0,0 +1,181 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.TagMapFuzzTest.MapAction; +import datadog.trace.api.TagMapFuzzTest.TestCase; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Supplier; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Fuzzes TagMap with the dense store enabled, replaying random action sequences against a {@code + * HashMap} oracle through {@link TagMapFuzzTest}'s machinery (which also runs {@code + * checkIntegrity} after each step). + * + *

    Keys come from three pools: slotted known tags (dense), known tags without a slot, and custom + * tags (both bucketed). Known tags are drawn by their canonical Datadog name, excluding names + * shared by a tag per direction, so the oracle's keys match TagMap's. The slotted pool is larger + * than the dense arrays' initial capacity, so growth is exercised, and includes tags that share a + * slot. Forked because the flag is read once per JVM. + */ +class TagMapDenseFuzzForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + static final int SINGLE_MAP_CASES = 1500; + static final int MERGE_CASES = 400; + static final int MAX_ACTIONS = 40; + static final int MIN_ACTIONS = 8; + static final int CUSTOM_SPACE = 48; + + static final List SLOTTED = new ArrayList<>(); + static final List UNSLOTTED = new ArrayList<>(); + + enum Regime { + DENSE_ONLY, + BUCKET_ONLY, + MIXED + } + + @BeforeAll + static void collectKeys() throws IllegalAccessException { + assertTrue(Config.get().isTraceDenseTagsEnabled(), "dense store must be on in this JVM"); + Set slotted = new LinkedHashSet<>(); + Set unslotted = new LinkedHashSet<>(); + for (Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) continue; + long id = field.getLong(null); + String name = KnownTagCodec.nameOf(id); + if (name == null || KnownTagCodec.keyOf(name) != id) continue; // shared per-direction name + (KnownTagCodec.hasSlot(id) ? slotted : unslotted).add(name); + } + SLOTTED.addAll(slotted); + UNSLOTTED.addAll(unslotted); + assertTrue(SLOTTED.size() > 12, "slotted pool should outgrow the dense arrays' initial size"); + assertTrue(!UNSLOTTED.isEmpty(), "need known tags without a slot"); + } + + @Test + void denseOnlyFuzz() { + runRegime(Regime.DENSE_ONLY); + } + + @Test + void bucketOnlyFuzz() { + runRegime(Regime.BUCKET_ONLY); + } + + @Test + void mixedFuzz() { + runRegime(Regime.MIXED); + } + + private static void runRegime(Regime regime) { + for (int i = 0; i < SINGLE_MAP_CASES; ++i) { + TagMapFuzzTest.test(TagMapType.DENSE, generateTest(regime)); + } + for (int i = 0; i < MERGE_CASES; ++i) { + TagMap mapA = TagMapFuzzTest.test(TagMapType.DENSE, generateTest(regime)); + TagMap mapB = TagMapFuzzTest.test(TagMapType.DENSE, generateTest(regime)); + + HashMap hashA = new HashMap<>(mapA); + HashMap hashB = new HashMap<>(mapB); + + mapA.putAll(mapB); + hashA.putAll(hashB); + + TagMapFuzzTest.assertMapEquals(hashA, mapA); + } + } + + private static TestCase generateTest(Regime regime) { + ThreadLocalRandom r = ThreadLocalRandom.current(); + int numActions = r.nextInt(MAX_ACTIONS - MIN_ACTIONS) + MIN_ACTIONS; + List actions = new ArrayList<>(numActions); + for (int i = 0; i < numActions; ++i) { + actions.add(randomAction(regime)); + } + return new TestCase(actions); + } + + private static MapAction randomAction(Regime regime) { + switch (randomChoice(0.02, 0.1, 0.2)) { + case 0: + return TagMapFuzzTest.clear(); + case 1: + return choose( + () -> TagMapFuzzTest.putAll(randomKeysAndValues(regime)), + () -> TagMapFuzzTest.putAllTagMap(randomKeysAndValues(regime)), + () -> TagMapFuzzTest.putAllLedger(randomKeysAndValues(regime))); + case 2: + return choose( + () -> TagMapFuzzTest.remove(randomKey(regime)), + () -> TagMapFuzzTest.removeLight(randomKey(regime)), + () -> TagMapFuzzTest.getAndRemove(randomKey(regime))); + default: + return choose( + () -> TagMapFuzzTest.put(randomKey(regime), randomValue()), + () -> TagMapFuzzTest.set(randomKey(regime), randomValue()), + () -> TagMapFuzzTest.getAndSet(randomKey(regime), randomValue())); + } + } + + private static String randomKey(Regime regime) { + ThreadLocalRandom r = ThreadLocalRandom.current(); + boolean dense; + switch (regime) { + case DENSE_ONLY: + dense = true; + break; + case BUCKET_ONLY: + dense = false; + break; + default: + dense = r.nextBoolean(); + } + if (dense) { + // every known tag is dense, slotted or not + return r.nextInt(4) == 0 + ? UNSLOTTED.get(r.nextInt(UNSLOTTED.size())) + : SLOTTED.get(r.nextInt(SLOTTED.size())); + } + return "custom-" + r.nextInt(CUSTOM_SPACE); + } + + private static String randomValue() { + return "values-" + ThreadLocalRandom.current().nextInt(); + } + + private static String[] randomKeysAndValues(Regime regime) { + int numEntries = ThreadLocalRandom.current().nextInt(SLOTTED.size() + CUSTOM_SPACE); + String[] keysAndValues = new String[numEntries << 1]; + for (int i = 0; i < keysAndValues.length; i += 2) { + keysAndValues[i] = randomKey(regime); + keysAndValues[i + 1] = randomValue(); + } + return keysAndValues; + } + + private static int randomChoice(double... proportions) { + double selector = ThreadLocalRandom.current().nextDouble(); + for (int i = 0; i < proportions.length; ++i) { + if (selector < proportions[i]) return i; + selector -= proportions[i]; + } + return proportions.length; + } + + @SafeVarargs + private static MapAction choose(Supplier... choices) { + return choices[ThreadLocalRandom.current().nextInt(choices.length)].get(); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapFactoryInitOrderForkedTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapFactoryInitOrderForkedTest.java new file mode 100644 index 00000000000..e5a81718821 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapFactoryInitOrderForkedTest.java @@ -0,0 +1,31 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * The factories can initialize before {@link TagMap} (forked, so this JVM hasn't initialized + * either). A factory that built its empty map in its own initializer started TagMap's initializer + * there, which read the still-null {@link TagMapFactory#INSTANCE} and poisoned TagMap for the JVM. + */ +class TagMapFactoryInitOrderForkedTest { + @Test + void theFactoriesCanInitializeBeforeTagMap() { + TagMapFactory hash = HashTagMapFactory.INSTANCE; + TagMapFactory dense = DenseTagMapFactory.INSTANCE; + + assertTrue(hash.empty().isEmpty()); + assertTrue(dense.empty().isEmpty()); + assertNotNull(TagMap.EMPTY); + assertSame(TagMap.EMPTY, TagMapFactory.Default.INSTANCE.empty()); + assertNotSame(hash.empty(), dense.empty()); + TagMap map = TagMap.create(); + map.set("a", "b"); + assertEquals("b", map.getString("a")); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java index 685dc1d9cdf..3289c34ddc2 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapFuzzTest.java @@ -13,25 +13,28 @@ import java.util.Map; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Supplier; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; public final class TagMapFuzzTest { static final int NUM_KEYS = 128; static final int MAX_NUM_ACTIONS = 32; static final int MIN_NUM_ACTIONS = 8; - @Test - void test() { - test(generateTest()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void test(TagMapType type) { + test(type, generateTest()); } - @Test - void testMerge() { + @ParameterizedTest + @EnumSource(TagMapType.class) + void testMerge(TagMapType type) { TestCase mapACase = generateTest(); TestCase mapBCase = generateTest(); - TagMap tagMapA = test(mapACase); - TagMap tagMapB = test(mapBCase); + TagMap tagMapA = test(type, mapACase); + TagMap tagMapB = test(type, mapBCase); HashMap hashMapA = new HashMap<>(tagMapA); HashMap hashMapB = new HashMap<>(tagMapB); @@ -42,10 +45,12 @@ void testMerge() { assertMapEquals(hashMapA, tagMapA); } - @Test - void priorFailingCase0() { + @ParameterizedTest + @EnumSource(TagMapType.class) + void priorFailingCase0(TagMapType type) { TagMap map = makeTagMap( + type, remove("key-4"), put("key-71", "values-443049055"), put("key-2", "values-1227065898"), @@ -302,9 +307,11 @@ void priorFailingCase0() { failingAction.verifyTestMap(map); } - @Test - void priorFailingCase1() { - TagMap map = makeTagMap(put("key-68", "values--37178328"), put("key-93", "values--2093086281")); + @ParameterizedTest + @EnumSource(TagMapType.class) + void priorFailingCase1(TagMapType type) { + TagMap map = + makeTagMap(type, put("key-68", "values--37178328"), put("key-93", "values--2093086281")); MapAction failingAction = putAllTagMap( @@ -522,8 +529,9 @@ void priorFailingCase1() { failingAction.verifyTestMap(map); } - @Test - void priorFailingCase2() { + @ParameterizedTest + @EnumSource(TagMapType.class) + void priorFailingCase2(TagMapType type) { TestCase testCase = new TestCase( remove("key-34"), @@ -858,7 +866,7 @@ void priorFailingCase2() { put("key-41", "values--904162962")); Map expected = makeMap(testCase); - TagMap actual = makeTagMap(testCase); + TagMap actual = makeTagMap(type, testCase); MapAction failingAction = remove("key-127"); failingAction.applyToExpectedMap(expected); @@ -869,8 +877,8 @@ void priorFailingCase2() { assertMapEquals(expected, actual); } - public static final TagMap test(MapAction... actions) { - return test(new TestCase(Arrays.asList(actions))); + public static final TagMap test(TagMapType type, MapAction... actions) { + return test(type, new TestCase(Arrays.asList(actions))); } public static final Map makeMap(TestCase testCase) { @@ -889,27 +897,27 @@ public static final Map makeMap(List actions) { return map; } - public static final TagMap makeTagMap(TestCase testCase) { - return makeTagMap(testCase.actions); + public static final TagMap makeTagMap(TagMapType type, TestCase testCase) { + return makeTagMap(type, testCase.actions); } - public static final TagMap makeTagMap(MapAction... actions) { - return makeTagMap(Arrays.asList(actions)); + public static final TagMap makeTagMap(TagMapType type, MapAction... actions) { + return makeTagMap(type, Arrays.asList(actions)); } - public static final TagMap makeTagMap(List actions) { - TagMap map = new TagMap(); + public static final TagMap makeTagMap(TagMapType type, List actions) { + TagMap map = type.create(); for (MapAction action : actions) { action.applyToTestMap(map); } return map; } - public static final TagMap test(TestCase test) { + public static final TagMap test(TagMapType type, TestCase test) { List actions = test.actions(); Map hashMap = new HashMap<>(); - TagMap tagMap = new TagMap(); + TagMap tagMap = type.create(); int actionIndex = 0; try { @@ -1099,8 +1107,8 @@ static final Map mapOf(String... keysAndValues) { return map; } - static final TagMap tagMapOf(String... keysAndValues) { - TagMap map = new TagMap(); + static final TagMap tagMapOf(TagMapType type, String... keysAndValues) { + TagMap map = type.create(); for (int i = 0; i < keysAndValues.length; i += 2) { String key = keysAndValues[i]; String value = keysAndValues[i + 1]; @@ -1112,6 +1120,10 @@ static final TagMap tagMapOf(String... keysAndValues) { return map; } + static final TagMapType typeOf(TagMap map) { + return map instanceof DenseTagMap ? TagMapType.DENSE : TagMapType.HASH; + } + static final TagMap.Ledger ledgerOf(String... keysAndValues) { TagMap.Ledger ledger = TagMap.ledger(); for (int i = 0; i < keysAndValues.length; i += 2) { @@ -1359,27 +1371,26 @@ public String toString() { static final class PutAllTagMap extends BasicAction { final String[] keysAndValues; - final TagMap tagMap; PutAllTagMap(String... keysAndValues) { this.keysAndValues = keysAndValues; - this.tagMap = tagMapOf(keysAndValues); } @Override protected void _applyToTestMap(TagMap testMap) { - testMap.putAll(this.tagMap); + // the source map is built through the test map's own store, so the two never mix stores + testMap.putAll(tagMapOf(typeOf(testMap), this.keysAndValues)); } @Override protected void _applyToExpectedMap(Map expectedMap) { - expectedMap.putAll(this.tagMap); + expectedMap.putAll(mapOf(this.keysAndValues)); } @Override public void verifyTestMap(TagMap expectedMap) { - for (TagMap.EntryReader entry : this.tagMap) { - assertEquals(entry.objectValue(), expectedMap.get(entry.tag()), "key=" + entry.tag()); + for (Map.Entry entry : mapOf(this.keysAndValues).entrySet()) { + assertEquals(entry.getValue(), expectedMap.get(entry.getKey()), "key=" + entry.getKey()); } } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapHashOnlyForkedTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapHashOnlyForkedTest.java new file mode 100644 index 00000000000..50f8a051aba --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapHashOnlyForkedTest.java @@ -0,0 +1,30 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; + +/** + * With the dense store off, TagMap is the hash-only implementation, and the dense one is never + * loaded: with one TagMap subclass loaded, every TagMap call devirtualizes without a guard. Runs in + * its own JVM, so no other test has loaded the dense implementation first. + */ +class TagMapHashOnlyForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "false"); + } + + @Test + void onlyTheHashOnlyImplementationIsLoaded() { + TagMap map = TagMap.create(); + map.set(KnownTags.COMPONENT_ID, "okhttp"); + map.set("custom.tag", "value"); + TagMap copy = TagMap.createFromParent(map.freeze()); + + assertSame(HashTagMap.class, map.getClass()); + assertSame(HashTagMap.class, copy.getClass()); + assertSame(HashTagMap.class, TagMap.EMPTY.getClass()); + assertFalse(TagMapImplementations.isLoaded("datadog.trace.api.DenseTagMap")); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapImplementations.java b/internal-api/src/test/java/datadog/trace/api/TagMapImplementations.java new file mode 100644 index 00000000000..3b3fc9c459e --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapImplementations.java @@ -0,0 +1,30 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.lang.reflect.Method; + +/** Whether a TagMap implementation class has been loaded, without loading it. */ +final class TagMapImplementations { + private TagMapImplementations() {} + + /** + * True if {@code className} has been loaded by TagMap's class loader. Skips the calling test when + * the JVM denies the reflective access this needs (JDK 17+ without {@code --add-opens}). + */ + 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/TagMapReadThroughTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java index 5001994e2b0..b1d6331d4e2 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java @@ -11,7 +11,8 @@ import java.util.Iterator; import java.util.Map; import java.util.Set; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; /** * Read-through support, slice 1 (read path): a child {@link TagMap} with a frozen parent reads @@ -20,17 +21,18 @@ */ class TagMapReadThroughTest { - private static TagMap frozenParent() { - TagMap parent = TagMap.create(); + private static TagMap frozenParent(TagMapType type) { + TagMap parent = type.create(); parent.set("a", "parent-a"); parent.set("b", "parent-b"); parent.freeze(); return parent; } - @Test - void readsThroughToParentOnMiss() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void readsThroughToParentOnMiss(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("c", "child-c"); assertEquals("parent-a", child.getString("a")); // miss locally -> read through @@ -41,18 +43,20 @@ void readsThroughToParentOnMiss() { assertFalse(child.containsKey("missing")); } - @Test - void localEntryShadowsParent() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void localEntryShadowsParent(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("b", "child-b"); // same key as parent assertEquals("child-b", child.getString("b")); // local wins assertEquals("parent-a", child.getString("a")); // parent still visible } - @Test - void estimateSizeIsUpperBound() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void estimateSizeIsUpperBound(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("b", "child-b"); // shadows parent "b" child.set("c", "child-c"); @@ -61,31 +65,34 @@ void estimateSizeIsUpperBound() { assertTrue(child.estimateSize() >= 3, "estimateSize must be an upper bound on the true size"); } - @Test - void emptinessSemantics() { - TagMap emptyOverEmpty = TagMap.createFromParent(TagMap.create().freeze()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void emptinessSemantics(TagMapType type) { + TagMap emptyOverEmpty = type.createFromParent(type.create().freeze()); assertTrue(emptyOverEmpty.isEmpty()); assertTrue(emptyOverEmpty.isDefinitelyEmpty()); - TagMap emptyOverNonEmpty = TagMap.createFromParent(frozenParent()); + TagMap emptyOverNonEmpty = type.createFromParent(frozenParent(type)); assertFalse(emptyOverNonEmpty.isEmpty(), "a non-empty parent makes the map non-empty"); assertFalse(emptyOverNonEmpty.isDefinitelyEmpty()); - assertTrue((TagMap.create()).isDefinitelyEmpty()); + assertTrue((type.create()).isDefinitelyEmpty()); } - @Test - void parentMustBeFrozen() { - TagMap mutableParent = TagMap.create(); - assertThrows(IllegalStateException.class, () -> TagMap.createFromParent(mutableParent)); + @ParameterizedTest + @EnumSource(TagMapType.class) + void parentMustBeFrozen(TagMapType type) { + TagMap mutableParent = type.create(); + assertThrows(IllegalStateException.class, () -> type.createFromParent(mutableParent)); } - @Test - void emptyParentIsDroppedNotAttached() { - TagMap emptyFrozen = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void emptyParentIsDroppedNotAttached(TagMapType type) { + TagMap emptyFrozen = type.create(); emptyFrozen.freeze(); - TagMap overEmpty = TagMap.createFromParent(emptyFrozen); + TagMap overEmpty = type.createFromParent(emptyFrozen); // an empty frozen parent contributes nothing and never will -> dropped, no read-through cost assertNull(overEmpty.parent, "empty parent should be dropped"); assertTrue(overEmpty.isDefinitelyEmpty()); @@ -93,17 +100,18 @@ void emptyParentIsDroppedNotAttached() { assertEquals("x-val", overEmpty.getString("x")); // a non-empty parent is still attached - TagMap overNonEmpty = TagMap.createFromParent(frozenParent()); + TagMap overNonEmpty = type.createFromParent(frozenParent(type)); assertNotNull(overNonEmpty.parent, "non-empty parent must be attached"); assertEquals("parent-a", overNonEmpty.getString("a")); } // --- slice 2: removal / tombstones --- - @Test - void removingParentKeyHidesItFromChildButNotFromParent() { - TagMap parent = frozenParent(); - TagMap child = TagMap.createFromParent(parent); + @ParameterizedTest + @EnumSource(TagMapType.class) + void removingParentKeyHidesItFromChildButNotFromParent(TagMapType type) { + TagMap parent = frozenParent(type); + TagMap child = type.createFromParent(parent); assertEquals("parent-a", child.getString("a")); // visible before removal child.remove("a"); @@ -114,18 +122,20 @@ void removingParentKeyHidesItFromChildButNotFromParent() { assertEquals("parent-a", parent.getString("a")); // frozen parent untouched } - @Test - void removeReturnsPriorVisibleValueViaParent() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void removeReturnsPriorVisibleValueViaParent(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); // Map.remove contract: the key was present (via read-through), so removal reports it. assertTrue(child.remove("a"), "removing a parent-exposed key should report it was present"); assertNull(child.getString("a")); } - @Test - void reSettingARemovedKeyRestoresVisibility() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void reSettingARemovedKeyRestoresVisibility(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.remove("a"); assertNull(child.getString("a")); @@ -134,16 +144,17 @@ void reSettingARemovedKeyRestoresVisibility() { assertEquals("child-a", child.getString("a")); } - @Test - void removingAKeyThatIsBothLocalAndParentHidesBoth() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void removingAKeyThatIsBothLocalAndParentHidesBoth(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("b", "child-b"); // shadows parent "b" assertEquals("child-b", child.getString("b")); child.remove("b"); assertNull(child.getString("b"), "removal must hide both the local entry and the parent's"); - assertEquals("parent-b", frozenParent().getString("b")); // parent still has it + assertEquals("parent-b", frozenParent(type).getString("b")); // parent still has it } // --- slice 3a: bulk forEach union + exact size/isEmpty --- @@ -154,9 +165,10 @@ private static Map collect(TagMap map) { return out; } - @Test - void forEachEmitsDedupedUnionLocalWins() { - TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + @ParameterizedTest + @EnumSource(TagMapType.class) + void forEachEmitsDedupedUnionLocalWins(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); // parent {a, b} child.set("b", "child-b"); // shadows parent "b" child.set("c", "child-c"); @@ -167,9 +179,10 @@ void forEachEmitsDedupedUnionLocalWins() { assertEquals("child-c", u.get("c")); } - @Test - void forEachSkipsTombstonedParentKeys() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void forEachSkipsTombstonedParentKeys(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("c", "child-c"); child.remove("a"); // tombstone parent's "a" @@ -180,9 +193,10 @@ void forEachSkipsTombstonedParentKeys() { assertEquals("child-c", u.get("c")); } - @Test - void biConsumerForEachAlsoEmitsUnion() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void biConsumerForEachAlsoEmitsUnion(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("c", "child-c"); Map out = new HashMap<>(); @@ -192,9 +206,10 @@ void biConsumerForEachAlsoEmitsUnion() { assertEquals("child-c", out.get("c")); } - @Test - void sizeIsExactUnion() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void sizeIsExactUnion(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("b", "child-b"); // shadows child.set("c", "child-c"); assertEquals(3, child.size()); // {a, b, c} — b deduped, not 4 @@ -203,9 +218,10 @@ void sizeIsExactUnion() { assertEquals(2, child.size()); // {b, c} } - @Test - void isEmptyExactWhenAllParentKeysTombstonedAndNoLocal() { - TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + @ParameterizedTest + @EnumSource(TagMapType.class) + void isEmptyExactWhenAllParentKeysTombstonedAndNoLocal(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); // parent {a, b} assertFalse(child.isEmpty()); child.remove("a"); @@ -216,9 +232,10 @@ void isEmptyExactWhenAllParentKeysTombstonedAndNoLocal() { // --- slice 3b: pull-based iterators / collection views --- - @Test - void iteratorEmitsDedupedUnion() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void iteratorEmitsDedupedUnion(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("b", "child-b"); // shadows parent "b" child.set("c", "child-c"); @@ -234,9 +251,10 @@ void iteratorEmitsDedupedUnion() { assertEquals("child-c", u.get("c")); } - @Test - void keySetReflectsUnionAndTombstones() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void keySetReflectsUnionAndTombstones(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("c", "child-c"); Set keys = child.keySet(); @@ -249,9 +267,10 @@ void keySetReflectsUnionAndTombstones() { assertFalse(child.keySet().contains("a")); } - @Test - void valuesAndEntrySetReflectUnion() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void valuesAndEntrySetReflectUnion(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("b", "child-b"); // shadows parent "b" assertEquals(2, child.entrySet().size()); // {a, b} — b deduped @@ -262,13 +281,14 @@ void valuesAndEntrySetReflectUnion() { // --- slice 3c: putAll from a read-through source copies the visible union, not just locals --- - @Test - void putAllFromReadThroughSourceCopiesFullVisibleUnion() { - TagMap source = TagMap.createFromParent(frozenParent()); // parent {a, b} + @ParameterizedTest + @EnumSource(TagMapType.class) + void putAllFromReadThroughSourceCopiesFullVisibleUnion(TagMapType type) { + TagMap source = type.createFromParent(frozenParent(type)); // parent {a, b} source.set("b", "child-b"); // shadows parent b source.set("c", "child-c"); - TagMap dest = TagMap.create(); // empty -> putAllIntoEmptyMap path + TagMap dest = type.create(); // empty -> putAllIntoEmptyMap path dest.putAll(source); // the parent-visible "a" must land too, not just source's local entries @@ -278,11 +298,12 @@ void putAllFromReadThroughSourceCopiesFullVisibleUnion() { assertEquals("child-c", dest.getString("c")); } - @Test - void putAllMergeFromReadThroughSourceCopiesVisibleUnion() { - TagMap source = TagMap.createFromParent(frozenParent()); // {a, b} + @ParameterizedTest + @EnumSource(TagMapType.class) + void putAllMergeFromReadThroughSourceCopiesVisibleUnion(TagMapType type) { + TagMap source = type.createFromParent(frozenParent(type)); // {a, b} - TagMap dest = TagMap.create(); + TagMap dest = type.create(); dest.set("z", "dest-z"); // dest non-empty -> putAllMerge path dest.putAll(source); @@ -292,12 +313,13 @@ void putAllMergeFromReadThroughSourceCopiesVisibleUnion() { assertEquals("dest-z", dest.getString("z")); } - @Test - void putAllFromReadThroughSourceHonorsTombstones() { - TagMap source = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void putAllFromReadThroughSourceHonorsTombstones(TagMapType type) { + TagMap source = type.createFromParent(frozenParent(type)); source.remove("a"); // tombstone parent's "a" - TagMap dest = TagMap.create(); + TagMap dest = type.create(); dest.putAll(source); assertEquals(1, dest.size()); @@ -307,9 +329,10 @@ void putAllFromReadThroughSourceHonorsTombstones() { // --- slice 4: behavior-identical to a copy-down / flat map --- - @Test - void copyIsObservationallyIdentical() { - TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} + @ParameterizedTest + @EnumSource(TagMapType.class) + void copyIsObservationallyIdentical(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); // {a, b} child.set("b", "child-b"); // shadows parent "b" child.set("c", "child-c"); @@ -321,9 +344,10 @@ void copyIsObservationallyIdentical() { assertEquals(collect(child), collect(copy)); // same union } - @Test - void copyIsIndependentlyMutable() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void copyIsIndependentlyMutable(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("c", "child-c"); TagMap copy = child.copy(); @@ -336,9 +360,10 @@ void copyIsIndependentlyMutable() { assertNull(copy.getString("a")); } - @Test - void copyPreservesTombstones() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void copyPreservesTombstones(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.remove("a"); // tombstone "a" TagMap copy = child.copy(); @@ -347,13 +372,14 @@ void copyPreservesTombstones() { } /** The contract that lets the consumer flip mergedTracerTags to a parent. */ - @Test - void readThroughMatchesAnEquivalentFlatMap() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void readThroughMatchesAnEquivalentFlatMap(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("b", "child-b"); child.set("c", "child-c"); - TagMap flat = TagMap.create(); + TagMap flat = type.create(); flat.set("a", "parent-a"); flat.set("b", "child-b"); flat.set("c", "child-c"); @@ -366,9 +392,10 @@ void readThroughMatchesAnEquivalentFlatMap() { } } - @Test - void immutableCopyOfReadThroughIsFrozenAndStillReadsThrough() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void immutableCopyOfReadThroughIsFrozenAndStillReadsThrough(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("c", "child-c"); TagMap frozen = child.immutableCopy(); @@ -384,27 +411,28 @@ void immutableCopyOfReadThroughIsFrozenAndStillReadsThrough() { * Builds a 3-level chain leaf -> mid -> grandparent (both ancestors frozen) and returns the * leaf. Visible union, nearest-level-wins: {a=gp-a, b=mid-b, c=leaf-c, d=mid-d, e=leaf-e}. */ - private static TagMap threeLevelLeaf() { - TagMap grandparent = TagMap.create(); + private static TagMap threeLevelLeaf(TagMapType type) { + TagMap grandparent = type.create(); grandparent.set("a", "gp-a"); grandparent.set("b", "gp-b"); grandparent.set("c", "gp-c"); grandparent.freeze(); - TagMap mid = TagMap.createFromParent(grandparent); + TagMap mid = type.createFromParent(grandparent); mid.set("b", "mid-b"); // shadows grandparent b mid.set("d", "mid-d"); mid.freeze(); - TagMap leaf = TagMap.createFromParent(mid); + TagMap leaf = type.createFromParent(mid); leaf.set("c", "leaf-c"); // shadows grandparent c (mid doesn't define c) leaf.set("e", "leaf-e"); return leaf; } - @Test - void getWalksTheWholeChainNearestWins() { - TagMap leaf = threeLevelLeaf(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void getWalksTheWholeChainNearestWins(TagMapType type) { + TagMap leaf = threeLevelLeaf(type); assertEquals("gp-a", leaf.getString("a")); // only in grandparent (two levels up) assertEquals("mid-b", leaf.getString("b")); // mid shadows grandparent assertEquals("leaf-c", leaf.getString("c")); // leaf shadows grandparent @@ -413,14 +441,16 @@ void getWalksTheWholeChainNearestWins() { assertNull(leaf.getString("missing")); } - @Test - void sizeIsExactUnionAcrossChain() { - assertEquals(5, threeLevelLeaf().size()); // {a, b, c, d, e}, shadowed duplicates deduped + @ParameterizedTest + @EnumSource(TagMapType.class) + void sizeIsExactUnionAcrossChain(TagMapType type) { + assertEquals(5, threeLevelLeaf(type).size()); // {a, b, c, d, e}, shadowed duplicates deduped } - @Test - void forEachEmitsDedupedUnionAcrossChain() { - Map u = collect(threeLevelLeaf()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void forEachEmitsDedupedUnionAcrossChain(TagMapType type) { + Map u = collect(threeLevelLeaf(type)); assertEquals(5, u.size()); assertEquals("gp-a", u.get("a")); assertEquals("mid-b", u.get("b")); // nearest ancestor wins over grandparent @@ -429,10 +459,11 @@ void forEachEmitsDedupedUnionAcrossChain() { assertEquals("leaf-e", u.get("e")); } - @Test - void iteratorEmitsDedupedUnionAcrossChain() { + @ParameterizedTest + @EnumSource(TagMapType.class) + void iteratorEmitsDedupedUnionAcrossChain(TagMapType type) { Map u = new HashMap<>(); - Iterator it = threeLevelLeaf().iterator(); + Iterator it = threeLevelLeaf(type).iterator(); while (it.hasNext()) { TagMap.EntryReader e = it.next(); u.put(e.tag(), e.objectValue()); @@ -443,18 +474,20 @@ void iteratorEmitsDedupedUnionAcrossChain() { assertEquals("leaf-c", u.get("c")); } - @Test - void keySetReflectsChainUnion() { - Set keys = threeLevelLeaf().keySet(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void keySetReflectsChainUnion(TagMapType type) { + Set keys = threeLevelLeaf(type).keySet(); assertEquals(5, keys.size()); for (String k : new String[] {"a", "b", "c", "d", "e"}) { assertTrue(keys.contains(k), "missing key " + k); } } - @Test - void leafTombstoneHidesGrandparentOnlyKey() { - TagMap leaf = threeLevelLeaf(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void leafTombstoneHidesGrandparentOnlyKey(TagMapType type) { + TagMap leaf = threeLevelLeaf(type); leaf.remove("a"); // "a" lives only in the grandparent, two levels up assertNull(leaf.getString("a")); assertFalse(leaf.containsKey("a")); @@ -462,21 +495,22 @@ void leafTombstoneHidesGrandparentOnlyKey() { assertEquals(4, leaf.size()); // {b, c, d, e} } - @Test - void intermediateAncestorTombstoneIsHonoredByBulkViews() { + @ParameterizedTest + @EnumSource(TagMapType.class) + void intermediateAncestorTombstoneIsHonoredByBulkViews(TagMapType type) { // mid removes an inherited grandparent key, THEN is frozen and reused as a parent. A non-leaf // level can therefore carry its own tombstones. Point lookups recurse through mid's tombstone // (correct); the bulk views must agree and not re-emit the key. - TagMap grandparent = TagMap.create(); + TagMap grandparent = type.create(); grandparent.set("a", "gp-a"); grandparent.set("b", "gp-b"); grandparent.freeze(); - TagMap mid = TagMap.createFromParent(grandparent); + TagMap mid = type.createFromParent(grandparent); mid.remove("a"); // tombstone an inherited key before freezing mid.freeze(); - TagMap leaf = TagMap.createFromParent(mid); + TagMap leaf = type.createFromParent(mid); // point lookups (recurse -> already correct) assertNull(leaf.getString("a")); @@ -502,22 +536,23 @@ void intermediateAncestorTombstoneIsHonoredByBulkViews() { assertFalse(viaIterator.containsKey("a")); } - @Test - void observationallyEmptyIntermediateParentIsDroppedNotAttached() { + @ParameterizedTest + @EnumSource(TagMapType.class) + void observationallyEmptyIntermediateParentIsDroppedNotAttached(TagMapType type) { // A frozen mid level with no local entries whose every inherited key is tombstoned is // observationally empty even though a local (grandparent) level still holds entries. // createFromParent must drop it (exact isEmpty), so the leaf stays consistent: isEmpty() must // agree with size()/lookup/iteration. - TagMap grandparent = TagMap.create(); + TagMap grandparent = type.create(); grandparent.set("a", "gp-a"); grandparent.freeze(); - TagMap mid = TagMap.createFromParent(grandparent); + TagMap mid = type.createFromParent(grandparent); mid.remove("a"); // tombstone the only inherited key -> mid is observationally empty mid.freeze(); assertTrue(mid.isEmpty()); - TagMap leaf = TagMap.createFromParent(mid); + TagMap leaf = type.createFromParent(mid); assertEquals(0, leaf.size()); assertTrue(leaf.isEmpty(), "isEmpty() must agree with size()==0"); assertNull(leaf.getString("a")); @@ -525,11 +560,12 @@ void observationallyEmptyIntermediateParentIsDroppedNotAttached() { assertFalse(leaf.iterator().hasNext()); } - @Test - void chainReadThroughMatchesEquivalentFlatMap() { - TagMap leaf = threeLevelLeaf(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void chainReadThroughMatchesEquivalentFlatMap(TagMapType type) { + TagMap leaf = threeLevelLeaf(type); - TagMap flat = TagMap.create(); + TagMap flat = type.create(); flat.set("a", "gp-a"); flat.set("b", "mid-b"); flat.set("c", "leaf-c"); @@ -546,47 +582,53 @@ void chainReadThroughMatchesEquivalentFlatMap() { // --- slice 6: put/getAndSet report the prior visible value, including inherited --- - @Test - void putReturnsInheritedParentValueAsPrior() { - TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + @ParameterizedTest + @EnumSource(TagMapType.class) + void putReturnsInheritedParentValueAsPrior(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); // parent {a, b} Object prior = child.put("a", "child-a"); // "a" exists only in the parent assertEquals("parent-a", prior, "put must report the inherited value as the previous mapping"); assertEquals("child-a", child.getString("a")); // new value stored locally } - @Test - void putReturnsNullForAKeyInNeitherLocalNorParent() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void putReturnsNullForAKeyInNeitherLocalNorParent(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); assertNull(child.put("brand-new", "v")); } - @Test - void putReturnsLocalPriorWhenShadowingParent() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void putReturnsLocalPriorWhenShadowingParent(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("a", "local-a"); // local now shadows the parent assertEquals("local-a", child.put("a", "local-a2"), "the local prior wins over the parent's"); } - @Test - void putAfterRemoveReportsNoPriorNotTheParentValue() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void putAfterRemoveReportsNoPriorNotTheParentValue(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.remove("a"); // tombstone the parent's "a": no longer visible assertNull(child.put("a", "child-a"), "a tombstoned key had no visible prior value"); assertEquals("child-a", child.getString("a")); } - @Test - void getAndSetReturnsInheritedEntryAsPrior() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void getAndSetReturnsInheritedEntryAsPrior(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); TagMap.Entry prior = child.getAndSet("b", "child-b"); assertEquals("parent-b", prior.objectValue()); } - @Test - void getAndSetByIdReturnsTheInheritedEntryOfThatId() { - TagMap parent = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void getAndSetByIdReturnsTheInheritedEntryOfThatId(TagMapType type) { + TagMap parent = type.create(); parent.set(KnownTags.HTTP_ROUTE_ID, "/parent/{id}"); - TagMap child = TagMap.createFromParent(parent.freeze()); + TagMap child = type.createFromParent(parent.freeze()); TagMap.Entry prior = child.getAndSet(TagMap.Entry.create(KnownTags.HTTP_ROUTE_ID, (Object) "/child/{id}")); @@ -594,9 +636,10 @@ void getAndSetByIdReturnsTheInheritedEntryOfThatId() { assertEquals("/parent/{id}", prior.objectValue()); } - @Test - void setDoesNotReportPriorButStillClearsTombstone() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void setDoesNotReportPriorButStillClearsTombstone(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.remove("b"); // tombstone child.set("b", "child-b"); // void set: no prior lookup, but must clear the tombstone assertEquals("child-b", child.getString("b")); @@ -604,9 +647,10 @@ void setDoesNotReportPriorButStillClearsTombstone() { // --- slice 7: clear() removes inherited mappings too (detaches the parent) --- - @Test - void clearRemovesInheritedMappingsAndDetachesParent() { - TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} + @ParameterizedTest + @EnumSource(TagMapType.class) + void clearRemovesInheritedMappingsAndDetachesParent(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); // {a, b} child.set("c", "child-c"); child.clear(); @@ -618,17 +662,19 @@ void clearRemovesInheritedMappingsAndDetachesParent() { assertFalse(child.containsKey("a")); } - @Test - void clearDoesNotAffectTheFrozenParent() { - TagMap parent = frozenParent(); - TagMap child = TagMap.createFromParent(parent); + @ParameterizedTest + @EnumSource(TagMapType.class) + void clearDoesNotAffectTheFrozenParent(TagMapType type) { + TagMap parent = frozenParent(type); + TagMap child = type.createFromParent(parent); child.clear(); assertEquals("parent-a", parent.getString("a"), "the shared frozen parent is untouched"); } - @Test - void putAfterClearBehavesAsAPlainMap() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void putAfterClearBehavesAsAPlainMap(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.clear(); child.set("x", "x-val"); assertEquals(1, child.size()); @@ -650,9 +696,10 @@ private static Map fillStringMapCollect(TagMap map) { return out; } - @Test - void fillMapMaterializesDedupedUnionLocalWins() { - TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + @ParameterizedTest + @EnumSource(TagMapType.class) + void fillMapMaterializesDedupedUnionLocalWins(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); // parent {a, b} child.set("b", "child-b"); // shadows parent "b" child.set("c", "child-c"); @@ -663,9 +710,10 @@ void fillMapMaterializesDedupedUnionLocalWins() { assertEquals("child-c", out.get("c")); } - @Test - void fillMapHonorsTombstonedParentKeys() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void fillMapHonorsTombstonedParentKeys(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("c", "child-c"); child.remove("a"); // tombstone parent's "a" @@ -676,9 +724,10 @@ void fillMapHonorsTombstonedParentKeys() { assertEquals("child-c", out.get("c")); } - @Test - void fillStringMapMaterializesDedupedUnionLocalWins() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void fillStringMapMaterializesDedupedUnionLocalWins(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.set("b", "child-b"); // shadows parent "b" child.set("c", "child-c"); @@ -689,9 +738,10 @@ void fillStringMapMaterializesDedupedUnionLocalWins() { assertEquals("child-c", out.get("c")); } - @Test - void fillStringMapHonorsTombstonedParentKeys() { - TagMap child = TagMap.createFromParent(frozenParent()); + @ParameterizedTest + @EnumSource(TagMapType.class) + void fillStringMapHonorsTombstonedParentKeys(TagMapType type) { + TagMap child = type.createFromParent(frozenParent(type)); child.remove("a"); // tombstone parent's "a" Map out = fillStringMapCollect(child); @@ -700,11 +750,12 @@ void fillStringMapHonorsTombstonedParentKeys() { assertEquals("parent-b", out.get("b")); } - @Test - void fillMapAcrossChainMatchesEquivalentFlatMap() { - TagMap leaf = threeLevelLeaf(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void fillMapAcrossChainMatchesEquivalentFlatMap(TagMapType type) { + TagMap leaf = threeLevelLeaf(type); - TagMap flat = TagMap.create(); + TagMap flat = type.create(); flat.set("a", "gp-a"); flat.set("b", "mid-b"); flat.set("c", "leaf-c"); @@ -715,10 +766,11 @@ void fillMapAcrossChainMatchesEquivalentFlatMap() { assertEquals(fillStringMapCollect(flat), fillStringMapCollect(leaf)); } - @Test - void fillMapWithoutParentFillsOnlyLocalEntries() { + @ParameterizedTest + @EnumSource(TagMapType.class) + void fillMapWithoutParentFillsOnlyLocalEntries(TagMapType type) { // regression: the no-parent fast path is unchanged - TagMap flat = TagMap.create(); + TagMap flat = type.create(); flat.set("x", "x-val"); flat.set("y", "y-val"); diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapScenario.java b/internal-api/src/test/java/datadog/trace/api/TagMapScenario.java index 1c07b31c563..9fa6267386a 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapScenario.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapScenario.java @@ -24,4 +24,12 @@ public final TagMap create() { } return map; } + + public final TagMap create(TagMapType type) { + TagMap map = type.create(); + for (int i = 0; i < this.size; ++i) { + map.put("filler-key-" + i, "filler-value-" + i); + } + return map; + } } 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..fd73172d616 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapSetByIdTest.java @@ -5,13 +5,15 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.HashMap; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; /** {@link TagMap#set(long, Object)} and its overloads: setting a known tag by its id. */ class TagMapSetByIdTest { - @Test - void storesUnderTheCanonicalName() { - TagMap map = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void storesUnderTheCanonicalName(TagMapType type) { + TagMap map = type.create(); map.set(KnownTags.PEER_HOSTNAME_ID, "db.internal"); @@ -19,10 +21,11 @@ void storesUnderTheCanonicalName() { assertEquals(KnownTags.PEER_HOSTNAME_ID, map.getEntry(KnownTags.PEER_HOSTNAME_NAME).tagId()); } - @Test - void anOpenTelemetryRenameIsStoredUnderTheDatadogName() { - TagMap byId = TagMap.create(); - TagMap byOtelName = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void anOpenTelemetryRenameIsStoredUnderTheDatadogName(TagMapType type) { + TagMap byId = type.create(); + TagMap byOtelName = type.create(); byId.set(KnownTags.HTTP_METHOD_ID, "GET"); byOtelName.set(KnownTags.HTTP_METHOD_OTEL_NAME, "GET"); @@ -30,9 +33,10 @@ void anOpenTelemetryRenameIsStoredUnderTheDatadogName() { assertEquals(new HashMap<>(byOtelName), new HashMap<>(byId)); } - @Test - void matchesTheNameKeyedSettersForEveryValueType() { - TagMap byId = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void matchesTheNameKeyedSettersForEveryValueType(TagMapType type) { + TagMap byId = type.create(); byId.set(KnownTags.HTTP_STATUS_CODE_ID, 5432); byId.set(KnownTags.HTTP_RESEND_COUNT_ID, 2L); byId.set(KnownTags.PEER_HOSTNAME_ID, (CharSequence) "db.internal"); @@ -41,7 +45,7 @@ void matchesTheNameKeyedSettersForEveryValueType() { byId.set(KnownTags.DB_USER_ID, 1.5f); byId.set(KnownTags.DB_POOL_NAME_ID, 2.5d); - TagMap byName = TagMap.create(); + TagMap byName = type.create(); byName.set(KnownTags.HTTP_STATUS_CODE_NAME, 5432); byName.set(KnownTags.HTTP_RESEND_COUNT_NAME, 2L); byName.set(KnownTags.PEER_HOSTNAME_NAME, (CharSequence) "db.internal"); @@ -54,9 +58,10 @@ void matchesTheNameKeyedSettersForEveryValueType() { assertEquals(5432, byId.getEntry(KnownTags.HTTP_STATUS_CODE_NAME).intValue()); } - @Test - void getsAndRemovesById() { - TagMap map = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void getsAndRemovesById(TagMapType type) { + TagMap map = type.create(); map.set(KnownTags.HTTP_METHOD_NAME, "GET"); assertEquals("GET", map.getEntry(KnownTags.HTTP_METHOD_ID).stringValue()); @@ -65,23 +70,25 @@ void getsAndRemovesById() { assertNull(map.getAndRemove(KnownTags.HTTP_METHOD_ID)); } - @Test - void anUnknownIdIsRejected() { - TagMap map = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void anUnknownIdIsRejected(TagMapType type) { + TagMap map = type.create(); assertThrows(IllegalArgumentException.class, () -> map.set(0L, "value")); assertThrows(IllegalArgumentException.class, () -> map.set(KnownTagCodec.makeTagId(9999), 1)); } - @Test - void theCoresTrustedSetsStoreWhatTheCheckedSetsStore() { - TagMap checked = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + void theCoresTrustedSetsStoreWhatTheCheckedSetsStore(TagMapType type) { + TagMap checked = type.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(); + TagMap trusted = type.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); @@ -91,15 +98,39 @@ void theCoresTrustedSetsStoreWhatTheCheckedSetsStore() { assertEquals(KnownTags.HTTP_ROUTE_ID, trusted.getEntry(KnownTags.HTTP_ROUTE_NAME).tagId()); } - @Test - void theCoresTrustedSetsCoverTheRemainingValueShapes() { + @ParameterizedTest + @EnumSource(TagMapType.class) + void theCoresKnownValueReadMatchesGetEntry(TagMapType type) { + TagMap parent = type.create(); + parent.set(KnownTags.HTTP_ROUTE_ID, "/parent"); + parent.set(KnownTags.HTTP_METHOD_ID, "GET"); + parent.set(KnownTags.PEER_HOSTNAME_ID, "db.internal"); + TagMap child = type.createFromParent(parent.freeze()); + child.set(KnownTags.HTTP_STATUS_CODE_ID, 200); + child.set(KnownTags.HTTP_ROUTE_ID, "/child"); + child.remove(KnownTags.PEER_HOSTNAME_NAME); + + // local hit, boxed + assertEquals(200, child.internals().getKnownValue(KnownTags.HTTP_STATUS_CODE_ID)); + // local shadows the parent + assertEquals("/child", child.internals().getKnownValue(KnownTags.HTTP_ROUTE_ID)); + // read-through parent hit + assertEquals("GET", child.internals().getKnownValue(KnownTags.HTTP_METHOD_ID)); + // tombstoned parent value and plain miss + assertNull(child.internals().getKnownValue(KnownTags.PEER_HOSTNAME_ID)); + assertNull(child.internals().getKnownValue(KnownTags.DB_INSTANCE_ID)); + } + + @ParameterizedTest + @EnumSource(TagMapType.class) + void theCoresTrustedSetsCoverTheRemainingValueShapes(TagMapType type) { Object component = new StringBuilder("jdbc"); // not a String: exercises the Object overload - TagMap checked = TagMap.create(); + TagMap checked = type.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(); + TagMap trusted = type.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); diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapSyntheticAccessorTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapSyntheticAccessorTest.java index 3042436cb54..a44585d1e87 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapSyntheticAccessorTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapSyntheticAccessorTest.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test; /** - * TagMap and its nested classes have no synthetic accessors. Targeting Java 8, javac reaches a + * TagMap and its implementations have no synthetic accessors. Targeting Java 8, javac reaches a * private member of a nested or outer class through a generated {@code access$NNN} method, or a * constructor taking a dummy {@code $1} class. C2 refuses to inline a call whose signature names a * never-loaded class, and every accessor adds a level of inlining depth, so on TagMap's hot paths a @@ -21,7 +21,14 @@ class TagMapSyntheticAccessorTest { @Test void theTagMapFamilyHasNoSyntheticAccessors() { List> classes = new ArrayList<>(); - for (Class root : Arrays.asList(TagMap.class)) { + for (Class root : + Arrays.asList( + TagMap.class, + HashTagMap.class, + DenseTagMap.class, + TagMapFactory.class, + HashTagMapFactory.class, + DenseTagMapFactory.class)) { collect(root, classes); } List accessors = new ArrayList<>(); diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapTest.java index 9a72b7bdd60..688410efb2b 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -16,10 +17,13 @@ import java.util.Set; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.MethodSource; public class TagMapTest { // size is chosen to make sure to stress all types of collisions in the Map @@ -81,9 +85,9 @@ public void fromMapImmutable_nonEmptyMap() { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_put(TagMapScenario scenario) { - TagMap map = scenario.create(); + @MethodSource("typesAndScenarios") + public void map_put(TagMapType type, TagMapScenario scenario) { + TagMap map = scenario.create(type); Object prev = map.put("foo", "bar"); assertNull(prev); @@ -97,15 +101,15 @@ public void map_put(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void booleanEntry(TagMapScenario scenario) { + @MethodSource("typesAndScenarios") + public void booleanEntry(TagMapType type, TagMapScenario scenario) { String BOOL = "bool"; String UNSET = "unset"; boolean first = false; boolean second = true; - TagMap map = scenario.create(); + TagMap map = scenario.create(type); map.set(BOOL, first); TagMap.Entry firstEntry = map.getEntry(BOOL); @@ -219,9 +223,10 @@ public void booleanToNumericCoercion_false() { checkIntegrity(map); } - @Test - public void emptyToPrimitiveCoercion() { - TagMap map = TagMap.EMPTY; + @ParameterizedTest + @EnumSource(TagMapType.class) + public void emptyToPrimitiveCoercion(TagMapType type) { + TagMap map = type.empty(); // DQH - assert helpers also check getOrDefault, so they don't work here assertEquals(false, map.getBoolean("dne")); @@ -234,15 +239,15 @@ public void emptyToPrimitiveCoercion() { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void intEntry(TagMapScenario scenario) { + @MethodSource("typesAndScenarios") + public void intEntry(TagMapType type, TagMapScenario scenario) { String INT = "int"; String UNSET = "unset"; int first = 3142; int second = 2718; - TagMap map = scenario.create(); + TagMap map = scenario.create(type); map.set(INT, first); TagMap.Entry firstEntry = map.getEntry("int"); @@ -264,15 +269,15 @@ public void intEntry(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void longEntry(TagMapScenario scenario) { + @MethodSource("typesAndScenarios") + public void longEntry(TagMapType type, TagMapScenario scenario) { String LONG = "long"; String UNSET = "unset"; long first = 3142L; long second = 2718L; - TagMap map = scenario.create(); + TagMap map = scenario.create(type); map.set(LONG, first); TagMap.Entry firstEntry = map.getEntry("long"); @@ -295,15 +300,15 @@ public void longEntry(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void floatEntry(TagMapScenario scenario) { + @MethodSource("typesAndScenarios") + public void floatEntry(TagMapType type, TagMapScenario scenario) { String FLOAT = "float"; String UNSET = "unset"; float first = 3.14F; float second = 2.718F; - TagMap map = scenario.create(); + TagMap map = scenario.create(type); map.set(FLOAT, first); TagMap.Entry firstEntry = map.getEntry(FLOAT); @@ -326,15 +331,15 @@ public void floatEntry(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void doubleEntry(TagMapScenario scenario) { + @MethodSource("typesAndScenarios") + public void doubleEntry(TagMapType type, TagMapScenario scenario) { String DOUBLE = "double"; String UNSET = "unset"; double first = Math.PI; double second = Math.E; - TagMap map = scenario.create(); + TagMap map = scenario.create(type); map.set(DOUBLE, Math.PI); TagMap.Entry firstEntry = map.getEntry(DOUBLE); @@ -356,9 +361,10 @@ public void doubleEntry(TagMapScenario scenario) { checkIntegrity(map); } - @Test - public void empty() { - TagMap empty = TagMap.EMPTY; + @ParameterizedTest + @EnumSource(TagMapType.class) + public void empty(TagMapType type) { + TagMap empty = type.empty(); assertFrozen(empty); assertNull(empty.getEntry("foo")); @@ -368,12 +374,13 @@ public void empty() { checkIntegrity(empty); } - @Test - public void putAll_empty() { - // TagMap.EMPTY breaks the rules and uses a different size bucket array + @ParameterizedTest + @EnumSource(TagMapType.class) + public void putAll_empty(TagMapType type) { + // type.empty() breaks the rules and uses a different size bucket array // This test is just to verify that the common use of putAll still works with EMPTY - TagMap newMap = TagMap.create(); - newMap.putAll(TagMap.EMPTY); + TagMap newMap = type.create(); + newMap.putAll(type.empty()); assertSize(0, newMap); assertEmpty(newMap); @@ -382,9 +389,9 @@ public void putAll_empty() { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void clear(TagMapScenario scenario) { - TagMap map = scenario.create(); + @MethodSource("typesAndScenarios") + public void clear(TagMapType type, TagMapScenario scenario) { + TagMap map = scenario.create(type); assertSize(scenario.size(), map); assertEmptiness(scenario.size() == 0, map); @@ -397,9 +404,9 @@ public void clear(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_put_replacement(TagMapScenario scenario) { - TagMap map = scenario.create(); + @MethodSource("typesAndScenarios") + public void map_put_replacement(TagMapType type, TagMapScenario scenario) { + TagMap map = scenario.create(type); Object prev1 = map.put("foo", "bar"); assertNull(prev1); @@ -417,9 +424,9 @@ public void map_put_replacement(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_remove_Object(TagMapScenario scenario) { - TagMap map = scenario.create(); + @MethodSource("typesAndScenarios") + public void map_remove_Object(TagMapType type, TagMapScenario scenario) { + TagMap map = scenario.create(type); Object prev = map.remove((Object) "foo"); assertNull(prev); @@ -438,9 +445,9 @@ public void map_remove_Object(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_remove_String(TagMapScenario scenario) { - TagMap map = scenario.create(); + @MethodSource("typesAndScenarios") + public void map_remove_String(TagMapType type, TagMapScenario scenario) { + TagMap map = scenario.create(type); boolean hadPrev1 = map.remove("foo"); assertFalse(hadPrev1); @@ -459,9 +466,9 @@ public void map_remove_String(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void map_getAndRemove(TagMapScenario scenario) { - TagMap map = scenario.create(); + @MethodSource("typesAndScenarios") + public void map_getAndRemove(TagMapType type, TagMapScenario scenario) { + TagMap map = scenario.create(type); TagMap.Entry prevEntry1 = map.getAndRemove("foo"); assertNull(prevEntry1); @@ -480,9 +487,9 @@ public void map_getAndRemove(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void freeze(TagMapScenario scenario) { - TagMap map = scenario.create(); + @MethodSource("typesAndScenarios") + public void freeze(TagMapType type, TagMapScenario scenario) { + TagMap map = scenario.create(type); map.put("foo", "bar"); assertEntry("foo", "bar", map); @@ -499,9 +506,10 @@ public void freeze(TagMapScenario scenario) { checkIntegrity(map); } - @Test - public void emptyMap() { - TagMap map = TagMap.EMPTY; + @ParameterizedTest + @EnumSource(TagMapType.class) + public void emptyMap(TagMapType type) { + TagMap map = type.empty(); assertSize(0, map); assertEmpty(map); @@ -512,9 +520,9 @@ public void emptyMap() { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void putMany(TagMapScenario scenario) { - TagMap map = scenario.create(); + @MethodSource("typesAndScenarios") + public void putMany(TagMapType type, TagMapScenario scenario) { + TagMap map = scenario.create(type); int size = scenario.size(); fillMap(map, size); @@ -530,10 +538,10 @@ public void putMany(TagMapScenario scenario) { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void copyMany(TagMapScenario scenario) { + @MethodSource("typesAndScenarios") + public void copyMany(TagMapType type, TagMapScenario scenario) { int size = scenario.size(); - TagMap orig = scenario.create(); + TagMap orig = scenario.create(type); fillMap(orig, size); assertSize(scenario.size() + size, orig); @@ -550,10 +558,11 @@ public void copyMany(TagMapScenario scenario) { checkIntegrity(copy); } - @Test - public void immutableCopy() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void immutableCopy(TagMapType type) { int size = randomSize(); - TagMap orig = createTagMap(size); + TagMap orig = createTagMap(type, size); TagMap immutableCopy = orig.immutableCopy(); orig.clear(); // doing this to make sure that copied isn't modified @@ -568,10 +577,11 @@ public void immutableCopy() { checkIntegrity(immutableCopy); } - @Test - public void replaceALot() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void replaceALot(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); for (int i = 0; i < size; ++i) { int index = ThreadLocalRandom.current().nextInt(size); @@ -583,12 +593,13 @@ public void replaceALot() { checkIntegrity(map); } - @Test - public void shareEntry() { - TagMap orig = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + public void shareEntry(TagMapType type) { + TagMap orig = type.create(); orig.set("foo", "bar"); - TagMap dest = TagMap.create(); + TagMap dest = type.create(); dest.set(orig.getEntry("foo")); assertEquals(orig.getEntry("foo"), dest.getEntry("foo")); @@ -598,13 +609,14 @@ public void shareEntry() { checkIntegrity(dest); } - @Test - public void putAll_clobberAll() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void putAll_clobberAll(TagMapType type) { int size = randomSize(); - TagMap orig = createTagMap(size); + TagMap orig = createTagMap(type, size); assertSize(size, orig); - TagMap dest = TagMap.create(); + TagMap dest = type.create(); for (int i = size - 1; i >= 0; --i) { dest.set(key(i), altValue(i)); } @@ -622,13 +634,13 @@ public void putAll_clobberAll() { } @ParameterizedTest - @EnumSource(TagMapScenario.class) - public void putAll_cloberAll(TagMapScenario scenario) { + @MethodSource("typesAndScenarios") + public void putAll_cloberAll(TagMapType type, TagMapScenario scenario) { int size = scenario.size(); - TagMap orig = scenario.create(); + TagMap orig = scenario.create(type); assertSize(size, orig); - TagMap dest = scenario.create(); + TagMap dest = scenario.create(type); assertSize(size, dest); dest.putAll(orig); @@ -640,13 +652,14 @@ public void putAll_cloberAll(TagMapScenario scenario) { checkIntegrity(dest); } - @Test - public void putAll_clobberAndExtras() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void putAll_clobberAndExtras(TagMapType type) { int size = randomSize(); - TagMap orig = createTagMap(size); + TagMap orig = createTagMap(type, size); assertSize(size, orig); - TagMap dest = TagMap.create(); + TagMap dest = type.create(); for (int i = size / 2 - 1; i >= 0; --i) { dest.set(key(i), altValue(i)); } @@ -664,10 +677,11 @@ public void putAll_clobberAndExtras() { checkIntegrity(dest); } - @Test - public void removeMany() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void removeMany(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); for (int i = 0; i < size; ++i) { assertEntry(key(i), value(i), map); @@ -689,10 +703,11 @@ public void removeMany() { checkIntegrity(map); } - @Test - public void fillMap() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void fillMap(TagMapType type) { int size = randomSize(); - TagMap map = TagMap.create(); + TagMap map = type.create(); for (int i = 0; i < size; ++i) { map.set(key(i), i); } @@ -708,10 +723,11 @@ public void fillMap() { checkIntegrity(map); } - @Test - public void fillStringMap() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void fillStringMap(TagMapType type) { int size = randomSize(); - TagMap map = TagMap.create(); + TagMap map = type.create(); for (int i = 0; i < size; ++i) { map.set(key(i), i); } @@ -727,10 +743,11 @@ public void fillStringMap() { checkIntegrity(map); } - @Test - public void iterator() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void iterator(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); Set keys = new HashSet<>(); for (TagMap.EntryReader entry : map) { @@ -749,10 +766,11 @@ public void iterator() { checkIntegrity(map); } - @Test - public void forEachConsumer() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void forEachConsumer(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); Set keys = new HashSet<>(size); map.forEach((entry) -> keys.add(entry.tag())); @@ -768,10 +786,11 @@ public void forEachConsumer() { checkIntegrity(map); } - @Test - public void forEachBiConsumer() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void forEachBiConsumer(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); Set keys = new HashSet<>(size); map.forEach(keys, (k, entry) -> k.add(entry.tag())); @@ -787,10 +806,11 @@ public void forEachBiConsumer() { checkIntegrity(map); } - @Test - public void forEachTriConsumer() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void forEachTriConsumer(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); Set keys = new HashSet<>(size); map.forEach(keys, "hi", (k, msg, entry) -> keys.add(entry.tag())); @@ -806,10 +826,11 @@ public void forEachTriConsumer() { checkIntegrity(map); } - @Test - public void entrySet() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void entrySet(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); Set> actualEntries = map.entrySet(); assertEquals(size, actualEntries.size()); @@ -824,10 +845,11 @@ public void entrySet() { checkIntegrity(map); } - @Test - public void keySet() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void keySet(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); Set actualKeys = map.keySet(); assertEquals(size, actualKeys.size()); @@ -842,10 +864,11 @@ public void keySet() { checkIntegrity(map); } - @Test - public void values() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void values(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); Collection actualValues = map.values(); assertEquals(size, actualValues.size()); @@ -860,10 +883,11 @@ public void values() { checkIntegrity(map); } - @Test - public void _toString() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void _toString(TagMapType type) { int size = 4; - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); String str = map.toString(); @@ -872,10 +896,11 @@ public void _toString() { } } - @Test - public void stream() { + @ParameterizedTest + @EnumSource(TagMapType.class) + public void stream(TagMapType type) { int size = randomSize(); - TagMap map = createTagMap(size); + TagMap map = createTagMap(type, size); Set keys = map.stream().map(TagMap.EntryReader::tag).collect(Collectors.toSet()); @@ -885,9 +910,10 @@ public void stream() { } } - @Test - public void compute() { - TagMap map = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + public void compute(TagMapType type) { + TagMap map = type.create(); map.set("key", "original"); map.compute("key", (k, v) -> "updated"); @@ -897,9 +923,10 @@ public void compute() { assertEquals("created", map.get("new-key")); } - @Test - public void computeIfAbsent() { - TagMap map = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + public void computeIfAbsent(TagMapType type) { + TagMap map = type.create(); map.set("key", "existing"); map.computeIfAbsent("key", k -> "ignored"); @@ -909,9 +936,10 @@ public void computeIfAbsent() { assertEquals("added", map.get("new-key")); } - @Test - public void computeIfPresent() { - TagMap map = TagMap.create(); + @ParameterizedTest + @EnumSource(TagMapType.class) + public void computeIfPresent(TagMapType type) { + TagMap map = type.create(); map.set("key", "original"); map.computeIfPresent("key", (k, v) -> "updated"); @@ -922,9 +950,9 @@ public void computeIfPresent() { } @ParameterizedTest - @ValueSource(ints = {0, 5, 25, 125}) - public void _toInternalString(int size) { - TagMap tagMap = new TagMap(); + @MethodSource("typesAndSizes") + public void _toInternalString(TagMapType type, int size) { + TagMap tagMap = type.create(); fillMap(tagMap, size); String str = tagMap.toInternalString(); @@ -934,16 +962,26 @@ public void _toInternalString(int size) { } } + static Stream typesAndScenarios() { + return Arrays.stream(TagMapType.values()) + .flatMap(type -> Arrays.stream(TagMapScenario.values()).map(s -> Arguments.of(type, s))); + } + + static Stream typesAndSizes() { + return Arrays.stream(TagMapType.values()) + .flatMap(type -> IntStream.of(0, 5, 25, 125).mapToObj(size -> Arguments.of(type, size))); + } + static int randomSize() { return ThreadLocalRandom.current().nextInt(16, MANY_SIZE); } - static TagMap createTagMap() { - return createTagMap(randomSize()); + static TagMap createTagMap(TagMapType type) { + return createTagMap(type, randomSize()); } - static TagMap createTagMap(int size) { - TagMap map = TagMap.create(); + static TagMap createTagMap(TagMapType type, int size) { + TagMap map = type.create(); fillMap(map, size); return map; } diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapType.java b/internal-api/src/test/java/datadog/trace/api/TagMapType.java new file mode 100644 index 00000000000..bda3a2cf96a --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapType.java @@ -0,0 +1,32 @@ +package datadog.trace.api; + +/** + * The two {@link TagMap} stores, for tests that run against both in one JVM. In production the + * dense-store flag picks one store per JVM; here each value builds maps through its own factory, so + * a test must create, parent and copy through the type it was given, never through {@link TagMap}'s + * static factories, which use the JVM's default store. + */ +public enum TagMapType { + HASH(HashTagMapFactory.INSTANCE), + DENSE(DenseTagMapFactory.INSTANCE); + + final TagMapFactory factory; + + TagMapType(TagMapFactory factory) { + this.factory = factory; + } + + public final TagMap create() { + return factory.create(null); + } + + /** {@link TagMap#createFromParent(TagMap)}, through this store. */ + public final TagMap createFromParent(TagMap parent) { + return TagMap.createFromParent(factory, parent, null); + } + + /** {@link TagMap#EMPTY} for this store. */ + public final TagMap empty() { + return factory.empty(); + } +} diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 45e12e44c85..9b28bf1b01a 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -5844,6 +5844,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 c4a5f9f9b70..0e3d9083809 100644 --- a/tag-conventions.yaml +++ b/tag-conventions.yaml @@ -2,9 +2,10 @@ # --------------------------------------------------------------------------- # The code generator consumes THIS file to emit each language's tag-id constants and its # id<->name resolver. A tag id is IDENTITY (a globally unique serial + the trace-level bit); -# storage layout (the dense store's slot assignment) and set-path routing (which keys the tracer -# intercepts into span fields or sampling directives) are per-language concerns that arrive with -# the code that consumes them, via a per-language overlay alongside this file. +# storage layout and set-path routing (which keys the tracer intercepts into span fields or +# sampling directives) are per-language concerns. A language derives its layout from the span types +# below -- dd-trace-java colors them into dense-store slots -- and declares its routing in a +# per-language overlay alongside this file. # # TRACE-LEVEL is its own thing (its own TagMap "type" on the TraceSegment) — the process/trace # constants + product flags that are set once per trace, NOT per span. Declared explicitly in the @@ -53,10 +54,9 @@ # that OTel only uses that name for this tag (db.system, say, only ever names a database). It is # interim, and goes away once resolution is direction-aware. A rename on a concrete type with no # span-kind still requires it. -# The id coordinate (group-decl / field-decl) is NOT authored here — the generator assigns it: each -# declaration source (the trace-level tier, each span type, each mixin) is a group, and within a -# group `field-decl` numbers the dense (required/conditional/recommended) tags; the rest are -# bucketed. See the design doc. +# Storage layout is NOT authored here. dd-trace-java's generator derives a dense-store slot for each +# tag by graph coloring: tags resolved onto one concrete span type (or both trace-level) get +# distinct slots, and tags that never appear together may share one. # --------------------------------------------------------------------------- # Trace-level tier: its own TagMap on the TraceSegment. Set once per trace, not per span.