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 9faf4f4ea8e..49640f0ef6b 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 @@ -173,5 +173,12 @@ 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"; + /** + * Routes known tags through the dense (id-keyed) tag store instead of per-tag entries. + * Experimental, OFF by default. The {@code KnownTagCodec} is registered regardless; this flag + * only selects whether tags take the dense storage path. + */ + public static final String TRACE_DENSE_TAGS_ENABLED = "trace.experimental.dense.tags.enabled"; + private TracerConfig() {} } 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 c48d8f94df6..c3a63bdb87d 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 @@ -40,6 +40,7 @@ import datadog.trace.api.EndpointTracker; import datadog.trace.api.IdGenerationStrategy; import datadog.trace.api.InstrumenterConfig; +import datadog.trace.api.KnownTags; import datadog.trace.api.Pair; import datadog.trace.api.TagMap; import datadog.trace.api.TraceConfig; @@ -656,6 +657,13 @@ private CoreTracer( // preload this enum to avoid triggering classloading on the hot path TraceCollector.PublishState.values(); + // Register the KnownTagCodec resolver unconditionally so tag-id name resolution (keyOf/nameOf, + // OTel name mapping) is always live. Whether known tags actually take the dense store is a + // separate, const-folded decision (KnownTagCodec.DENSE_STORE, from + // trace.experimental.dense.tags.enabled); when that flag is off, tag storage is byte-identical + // to the bucket-only behavior. + KnownTags.init(); + if (reportInTracerFlare) { TracerFlare.addReporter(this); } @@ -2207,18 +2215,31 @@ protected static final DDSpanContext buildSpanContext( propagationTags, tracer.profilingContextIntegration, tracer.injectBaggageAsTags, - tracer.injectLinksAsTags); + tracer.injectLinksAsTags, + mergedTracerTagsNeedsIntercept ? null : mergedTracerTags); // By setting the tags on the context we apply decorators to any tags that have been set via // the builder. This is the order that the tags were added previously, but maybe the `tags` // set in the builder should come last, so that they override other tags. - context.setAllTags(mergedTracerTags, mergedTracerTagsNeedsIntercept); + // + // mergedTracerTags is trace-level shared state and the precedence floor (everything below + // overrides it). When it carries no interceptable tags it is attached as a read-through + // PARENT at construction (shared by reference, no per-span copy). When it does need + // interception, copy its entries in (the interceptor's per-span side-effects can't be + // shared by reference). + if (mergedTracerTagsNeedsIntercept) { + context.setAllTags(mergedTracerTags, true); + } context.setAllTags(tagLedger); context.setAllTags(coreTags, coreTagsNeedsIntercept); context.setAllTags(rootSpanTags, rootSpanTagsNeedsIntercept); context.setAllTags(contextualTags); - // remove version here since will be done later on the postProcessor. - // it will allow knowing if it will be set manually or not + // Version is added later by the postProcessor (InternalTagsAdder), only if not already set + // during the request. Config version is kept out of the trace-level bundle (see + // withTracerTags), so this removal now only wipes a version set via the span builder — + // keeping + // the existing semantics where a builder-set version is replaced by the config version. Under + // read-through this is a cheap local removal (version isn't in the parent, so no tombstone). context.removeTag(Tags.VERSION); return context; } @@ -2449,6 +2470,25 @@ static TagMap withTracerTags( Map userSpanTags, Config config, TraceConfig traceConfig) { final TagMap result = TagMap.create(userSpanTags.size() + 5); result.putAll(userSpanTags); + // Version is conditionally managed by InternalTagsAdder (added only when service == DD_SERVICE + // and not set during the request), so keep it OUT of the trace-level bundle. This matters under + // read-through: the bundle becomes a shared parent, and a per-span removeTag(VERSION) on a key + // that lived in the parent would mint a per-span tombstone. With version excluded here, the + // per-span removeTag (retained, to wipe a builder-set version) is a cheap local op, never a + // tombstone. + // + // EXCEPTION: when `version` is a split-service tag, the TagInterceptor derives the service name + // from it, so it must reach the interceptor. Keeping it in the bundle forces the intercepting + // seed path (a split tag makes the bundle needsIntercept=true -> copied, not a read-through + // parent), where the retained removeTag(VERSION) still deletes only a local copy -- so the + // split + // side-effect fires and no per-span tombstone is minted either way. + // + // Cold path: withTracerTags runs at setup / config-change, not per span (mergedTracerTags is + // cached on the config snapshot), so this getSplitByTags() lookup needn't be hoisted. + if (config == null || !config.getSplitByTags().contains(Tags.VERSION)) { + result.remove(Tags.VERSION); + } if (null != config) { // static if (!config.getEnv().isEmpty()) { result.set("env", config.getEnv()); 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 6120502ec09..a2d87e2c18b 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 @@ -243,7 +243,8 @@ public DDSpanContext( propagationTags, ProfilingContextIntegration.NoOp.INSTANCE, true, - true); + true, + null); } public DDSpanContext( @@ -293,9 +294,11 @@ public DDSpanContext( propagationTags, ProfilingContextIntegration.NoOp.INSTANCE, injectBaggageAsTags, - injectLinksAsTags); + injectLinksAsTags, + null); } + /** Back-compat ctor (no read-through parent); delegates with a null parent. */ public DDSpanContext( final DDTraceId traceId, final long spanId, @@ -322,6 +325,62 @@ public DDSpanContext( final ProfilingContextIntegration profilingContextIntegration, final boolean injectBaggageAsTags, final boolean injectLinksAsTags) { + 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, + 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) { assert traceCollector != null; this.traceCollector = traceCollector; @@ -350,7 +409,10 @@ public DDSpanContext( // The +1 is the magic number from the tags below that we set at the end, // and "* 4 / 3" is to make sure that we don't resize immediately final int capacity = Math.max((tagsSize <= 0 ? 3 : (tagsSize + 1)) * 4 / 3, 8); - this.unsafeTags = TagMap.create(capacity); + this.unsafeTags = + readThroughParent != null + ? TagMap.createFromParent(readThroughParent) + : TagMap.create(capacity); // must set this before setting the service and resource names below this.profilingContextIntegration = profilingContextIntegration; diff --git a/dd-trace-core/src/test/java/datadog/trace/core/WithTracerTagsVersionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/WithTracerTagsVersionTest.java new file mode 100644 index 00000000000..fb859ae0433 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/WithTracerTagsVersionTest.java @@ -0,0 +1,42 @@ +package datadog.trace.core; + +import static datadog.trace.api.config.TracerConfig.SPLIT_BY_TAGS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.trace.api.Config; +import datadog.trace.api.TagMap; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.test.junit.utils.config.WithConfig; +import org.junit.jupiter.api.Test; + +/** + * {@code withTracerTags} keeps {@code version} OUT of the trace-level bundle so a per-span {@code + * removeTag(VERSION)} doesn't mint a read-through tombstone -- EXCEPT when {@code version} is a + * split-service tag, where the {@code TagInterceptor} must still see it to derive the service name + * (regression guard for the level-split consumer). + */ +class WithTracerTagsVersionTest extends DDCoreJavaSpecification { + + private static TagMap tracerTagsWithVersion(Config config) { + TagMap userTags = TagMap.create(); + userTags.set(Tags.VERSION, "1.2.3"); + return CoreTracer.withTracerTags(userTags, config, null); + } + + @Test + void versionStrippedFromBundleByDefault() { + assertNull( + tracerTagsWithVersion(Config.get()).getString(Tags.VERSION), + "version is kept out of the trace-level bundle by default (avoids per-span tombstone)"); + } + + @Test + @WithConfig(key = SPLIT_BY_TAGS, value = "version") + void versionKeptInBundleWhenSplitByVersion() { + assertEquals( + "1.2.3", + tracerTagsWithVersion(Config.get()).getString(Tags.VERSION), + "version must stay in the bundle so split-by-tags can derive the service name"); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f718582d883..84dc80e23c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,7 @@ jmh = "1.37" # Profiling jmc = "8.1.0" jafar = "0.16.0" +jol = "0.17" # Web & Network jnr-unixsocket = "0.38.25" @@ -125,6 +126,7 @@ instrument-java = { module = "com.datadoghq:dd-instrument-java", version.ref = " jmc-common = { module = "org.openjdk.jmc:common", version.ref = "jmc" } jmc-flightrecorder = { module = "org.openjdk.jmc:flightrecorder", version.ref = "jmc" } jafar-tools = { module = "io.btrace:jafar-tools", version.ref = "jafar" } +jol-core = { module = "org.openjdk.jol:jol-core", version.ref = "jol" } # Web & Network okio = { module = "com.datadoghq.okio:okio", version.ref = "okio" } diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index 65be1563eda..4d48a434c19 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -282,6 +282,7 @@ dependencies { testImplementation("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}") testImplementation(libs.commons.math) testImplementation(libs.bundles.mockito) + testImplementation(libs.jol.core) } jmh { 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..b749f346aec --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java @@ -0,0 +1,150 @@ +package datadog.trace.api; + +import datadog.trace.bootstrap.instrumentation.api.Tags; +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; + +/** + * Deterministic allocation A/B for the dense known-tag store, using the REAL {@link KnownTags} + * resolver (a {@code StringIndex} probe + a constant-returning {@code switch} — allocation-free, + * exactly like production). An earlier synthetic prefix resolver allocated in {@code keyOf} + * (substring) and {@code nameOf} (concat), contaminating the dense arm; this measures the store, + * not the resolver. + * + *

Models how a real span's tags route: {@code today} = all custom (what ships now — every tag + * buckets, since nothing is registered as known), {@code dense} = the same tag count with a + * realistic fraction routed to the dense store (real known tag names) and the rest custom. Run with + * {@code -prof gc}; the {@code gc.alloc.rate.norm} (B/op) delta at the same {@code tagCount} is + * what enabling the dense store does to a real span's per-build allocation. + * + *

Results — buildMap, JDK 17 (Zulu 17.0.7, Apple Silicon), {@code -prof gc -f 1 -wi 2 -i 3}, + * 2026-07-08. Allocation is deterministic (±0.001 B/op); throughput on this run is NOT + * trustworthy (single fork, short) — read B/op only. + * + *

{@code
+ * scenario    tagCount=7   tagCount=12
+ * today          408 B/op     704 B/op
+ * dense          376 B/op     416 B/op
+ * allKnown       176 B/op     400 B/op
+ * }
+ * + *

Gate met: {@code dense < today} at both counts (the over-provision artifact is gone). The + * Entry-less win scales with the known-tag fraction — ~8% at 7 tags (~70% known), ~41% at 12; + * {@code allKnown} (the codegen endgame / read-through parent shape) reaches ~57% at 7. + * + *

Serialize paths (same run, B/op). {@code buildAndSerialize} (alloc-free {@code forEach} + * flyweight) adds a flat +16 B/op over {@code buildMap} in every scenario (7: 392, 12: 432 dense). + * {@code buildAndSerializeViaIterator} — the {@code EntryReader} enhanced-for modeling the count + * pre-pass at {@code TraceMapperV0_4:95} — adds a CONSTANT per-call cost (+56 custom / +80 dense, + * identical at 7 and 12 tags): that flat-vs-tagCount signature is the {@code EntryReaderIterator} + * OBJECT, NOT per-tag Entry — the iterator reuses a dense flyweight (TagMap:2182/2652). So the + * dense win SURVIVES serialization; the only nit is {@code iterator()} allocating one Iterator per + * call, which {@code forEach} avoids and which can be recycled away. + */ +@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 { + + // Real stored (dense-routed) tag names — a realistic web/db span's known set. + static final String[] KNOWN = + new String[] { + DDTags.BASE_SERVICE, + Tags.VERSION, + Tags.COMPONENT, + Tags.SPAN_KIND, + Tags.HTTP_METHOD, + Tags.HTTP_ROUTE, + Tags.DB_TYPE, + Tags.DB_INSTANCE, + Tags.PEER_HOSTNAME, + Tags.DB_USER, + DDTags.LANGUAGE_TAG_KEY, + Tags.PEER_PORT, + }; + + // today = all custom (all bucket, what ships now); dense = ~70% known + custom (a real span); + // allKnown = 100% known (the trace-tier read-through parent's shape — exercises lazy buckets). + @Param({"today", "dense", "allKnown"}) + String scenario; + + @Param({"7", "12"}) + int tagCount; + + private String[] keys; + private String[] values; + + @Setup(Level.Trial) + public void setup() { + KnownTags.init(); // registers the real (allocation-free) resolver + int knownCount; + if ("allKnown".equals(scenario)) { + knownCount = tagCount; // 100% known (<= KNOWN.length) + } else if ("dense".equals(scenario)) { + knownCount = (tagCount * 7) / 10; // ~70% known + custom + } else { + knownCount = 0; // today: all custom (all bucket) + } + this.keys = new String[tagCount]; + this.values = new String[tagCount]; + for (int i = 0; i < tagCount; i++) { + this.keys[i] = i < knownCount ? KNOWN[i] : "custom.tag." + i; + this.values[i] = "value-" + i; + } + } + + @Benchmark + public TagMap buildMap() { + TagMap m = TagMap.create(16); + for (int i = 0; i < tagCount; i++) { + m.set(keys[i], values[i]); + } + return m; + } + + @Benchmark + public void buildAndSerialize(Blackhole bh) { + TagMap m = TagMap.create(16); + for (int i = 0; i < tagCount; i++) { + m.set(keys[i], values[i]); + } + // forEach: the alloc-free flyweight emit for dense + m.forEach(reader -> bh.consume(reader.objectValue())); + bh.consume(m); + } + + @Benchmark + public void buildAndSerializeViaIterator(Blackhole bh) { + TagMap m = TagMap.create(16); + for (int i = 0; i < tagCount; i++) { + m.set(keys[i], values[i]); + } + // models the REAL serializer's count pre-pass (TraceMapperV0_4:95). The EntryReader iterator + // uses a reused dense flyweight (NO per-tag Entry alloc — TagMap:2182/2652), so the dense win + // SURVIVES; the only extra cost vs forEach is the EntryReaderIterator object itself (a fixed + // per-call cost, constant across tagCount — not per-tag). forEach avoids even that. + for (TagMap.EntryReader reader : m) { + bh.consume(reader.objectValue()); + } + bh.consume(m); + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java index e42a67ec9ea..c2e77b89451 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableMapBenchmark.java @@ -33,11 +33,55 @@ * 10+, falls back to the input map pre-10). {@code Map.copyOf}/{@code MapN} is the honest * immutable-map baseline, not {@code HashMap}. * + *

Also compared: {@link StringIndex} used as a string->int map — an open-addressed index plus + * a slot-aligned {@code int[]} of values ({@code SI_VALUES[indexOf(key)]}). {@code + * stringIndex_get*} goes through the instance wrapper; {@code support_get*} reads via {@code static + * final} arrays (the JIT folds the refs). No {@code iterate} arm — StringIndex is a lookup index, + * not an iteration structure; its map use case is the {@code indexOf}->parallel-array read. + * *

Lookups use {@code EQUAL_KEYS} (distinct String instances) to exercise {@code equals()}; * {@code *_sameKey} variants reuse the original interned key instances to show the identity fast * path — which is the common tracer case, since map keys are typically interned tag-name constants. - * (Results pending a fresh multi-JVM run — {@code Map.copyOf} only materializes the compact form on - * Java 10+.) + * + *

JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s). + * {@code get} uses distinct keys (exercises {@code equals()}); {@code sameKey} reuses the interned + * key (the {@code ==} fast path — the common tracer case): + * + *

{@code
+ * Structure              get    sameKey
+ * support (static)      1498     2081    (fastest)
+ * stringIndex (inst)    1363     1900
+ * hashMap               1216     1850
+ * linkedHashMap         1214       -
+ * tagMap                1167     1386
+ * tracerImmutableMap    1049     1364    (MapN)
+ * treeMap                656       -
+ * }
+ * + *

{@code iterate} (full traversal): + * + *

{@code
+ * tagMap.forEach        148    (fastest)
+ * linkedHashMap         136
+ * tracerImmutableMap    135    (MapN)
+ * treeMap               134
+ * hashMap               104
+ * tagMap (iterator)      96
+ * }
+ * + *

Key findings: + * + *

*/ // @Fork(5): get_tracerImmutableMap* (MapN reached via interface dispatch) is JIT-bimodal at fewer // forks — 5 @@ -71,12 +115,31 @@ static void fill(Map map) { } } + // StringIndex as a string->int map: an open-addressed index plus a slot-aligned int[] of values + // (VALUES[indexOf(key)]). support_* reads via static final arrays (JIT folds the refs to + // constants); stringIndex_* goes through the instance wrapper. Both share one placement -- + // StringIndex.of and Support.create place identically -- so SI_VALUES aligns with either. + static final int[] SI_HASHES; + static final String[] SI_NAMES; + static final int[] SI_VALUES; + + static { + StringIndex.Data data = StringIndex.EmbeddingSupport.create(INSERTION_KEYS); + SI_HASHES = data.hashes; + SI_NAMES = data.names; + SI_VALUES = new int[SI_HASHES.length]; + for (int i = 0; i < INSERTION_KEYS.length; ++i) { + SI_VALUES[StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, INSERTION_KEYS[i])] = i; + } + } + // Built once, never mutated -- safe to share across the reader threads. HashMap hashMap; LinkedHashMap linkedHashMap; TreeMap treeMap; TagMap tagMap; Map tracerImmutableMap; + StringIndex stringIndex; @Setup(Level.Trial) public void setUp() { @@ -92,6 +155,7 @@ public void setUp() { } // JDK compact immutable map (MapN on Java 10+); the agent's actual fixed-map representation. tracerImmutableMap = CollectionUtils.tryMakeImmutableMap(hashMap); + stringIndex = StringIndex.of(INSERTION_KEYS); } /** Per-thread lookup cursor so each reader thread cycles keys independently. */ @@ -199,4 +263,25 @@ public void iterate_tracerImmutableMap(Blackhole blackhole) { blackhole.consume(entry.getValue()); } } + + @Benchmark + public int stringIndex_get(Cursor cursor) { + return SI_VALUES[stringIndex.indexOf(cursor.nextKey())]; + } + + @Benchmark + public int stringIndex_get_sameKey(Cursor cursor) { + return SI_VALUES[stringIndex.indexOf(cursor.nextKey(INSERTION_KEYS))]; + } + + @Benchmark + public int support_get(Cursor cursor) { + return SI_VALUES[StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey())]; + } + + @Benchmark + public int support_get_sameKey(Cursor cursor) { + return SI_VALUES[ + StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextKey(INSERTION_KEYS))]; + } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java index 54b27604f3d..2cc60ab7bf3 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/ImmutableSetBenchmark.java @@ -35,34 +35,57 @@ * ({@code ImmutableCollections.SetN}), which is what the agent actually uses for fixed config * sets. Java 10+; falls back to {@code HashSet} pre-10. The realistic baseline for any * flat/immutable set comparison. + *
  • {@code stringIndex} — {@link StringIndex#contains} on the instance wrapper (one field load + * to reach the placed arrays, then an open-addressed probe). + *
  • {@code support} — the same probe via {@link StringIndex.EmbeddingSupport#indexOf} over + * {@code static final} arrays, so the JIT folds the refs to constants and there is nothing to + * dereference (the hot path StringIndex recommends). The {@code stringIndex}/{@code support} + * pair shows the indirection cost of the wrapper. * * *

    Lookups are interned (the {@code ==} fast path where a structure has one); misses are short * and never present. * - *

    Java 17 results (Apple M1, {@code @Fork(2)}, {@code @Threads(8)}; M ops/s = millions): + *

    JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s = + * millions): * *

    {@code
      * Structure              hit     miss
    - * hashSet               2159     1751    (fastest)
    - * tracerImmutableSet    1946     1633    (Set.copyOf / SetN)
    - * array                  926      584
    - * sortedArray            664      588
    - * treeSet                642      593
    + * support (static)      2320     2159    (fastest)
    + * hashSet               2198     2134
    + * stringIndex (inst)    2098     1548 *  (* miss bimodal -- see caveat)
    + * tracerImmutableSet    1914     1663    (Set.copyOf / SetN)
    + * array                  941      589
    + * sortedArray            685      610
    + * treeSet                657      610
      * }
    * *

    Key findings: * *

      - *
    • {@code HashSet} is fastest; {@link java.util.Set#copyOf} ({@code SetN}) trails by only ~10% - * on hit and ~7% on miss — and it's the compact, array-backed form the agent already uses for - * fixed config sets, so it's a strong default when the set is immutable. - *
    • {@code array} / {@code sortedArray} / {@code treeSet} cluster at ~0.6–0.9B — they scan, - * binary-search, or tree-walk per lookup, so they trail the hashed structures, most visibly - * on the miss path. + *
    • The static {@code Support} path is the fastest — it beats {@code HashSet} on hit and miss + * and crushes the scan/search/tree forms. + *
    • {@code stringIndex} (the instance wrapper) trails {@code Support} by the field-load + * indirection (~10% on hit), landing near {@code HashSet} — fine off the hot path, prefer + * {@code Support} on it. + *
    • {@link java.util.Set#copyOf} ({@code SetN}, the agent's compact fixed-set form) is ~1.2x + * behind {@code Support} on hit but the most compact (~27% smaller — no cached hashes, + * no 2x table). So StringIndex's edge over {@code SetN} is speed + the {@code + * indexOf}->parallel-array capability, not footprint; over {@code HashSet} it wins both. + *
    • {@code array} / {@code sortedArray} / {@code treeSet} trail the hashed structures, most on + * miss. *
    + * + *

    Caveat — the instance {@code stringIndex} miss is bimodal across forks (confirmed at + * {@code @Fork(10)}: 6 forks fast, 4 slow, nothing between). ~60% of forks compile to a fast mode + * (~2000, ≈ {@code support_miss} — the wrapper indirection is then free) and ~40% to a slow mode + * (~1070, ~half); each fork locks one at warmup. So the {@code 1548 ±27%} above is a mode-mix, not + * noise. Cause: C2 hoists the instance field-loads ({@code this.hashes}/{@code names}) out of the + * miss-path probe loop only in the fast mode; the static {@code Support} path const-folds those + * refs and is never bimodal ({@code support_miss} ±0.3%). Prefer {@code Support} where miss latency + * matters. */ -@Fork(2) +@Fork(5) // 5 forks settle the bimodal stringIndex_miss / interface-dispatch arms (see header) @Warmup(iterations = 2) @Measurement(iterations = 3) @Threads(8) @@ -84,12 +107,26 @@ static String[] newMisses() { return misses; } + // StringIndex static-Support mode: the placed arrays pulled into static final fields, so the JIT + // folds the refs to constants and Support.indexOf has nothing to dereference (the hot path the + // StringIndex class Javadoc recommends). Contrast support_* (these) with stringIndex_* (the + // instance wrapper, one field load) to see the indirection cost. + static final int[] SI_HASHES; + static final String[] SI_NAMES; + + static { + StringIndex.Data data = StringIndex.EmbeddingSupport.create(STRINGS); + SI_HASHES = data.hashes; + SI_NAMES = data.names; + } + // Built once, never mutated -- safe to share across the reader threads. String[] array; String[] sortedArray; HashSet hashSet; TreeSet treeSet; Set tracerImmutableSet; + StringIndex stringIndex; @Setup(Level.Trial) public void setUp() { @@ -99,6 +136,7 @@ public void setUp() { hashSet = new HashSet<>(Arrays.asList(STRINGS)); treeSet = new TreeSet<>(Arrays.asList(STRINGS)); tracerImmutableSet = CollectionUtils.tryMakeImmutableSet(Arrays.asList(STRINGS)); + stringIndex = StringIndex.of(STRINGS); } /** Per-thread lookup cursor so each reader thread cycles keys independently. */ @@ -184,4 +222,24 @@ public boolean tracerImmutableSet_hit(Cursor cursor) { public boolean tracerImmutableSet_miss(Cursor cursor) { return tracerImmutableSet.contains(cursor.nextMiss()); } + + @Benchmark + public boolean stringIndex_hit(Cursor cursor) { + return stringIndex.contains(cursor.nextHit()); + } + + @Benchmark + public boolean stringIndex_miss(Cursor cursor) { + return stringIndex.contains(cursor.nextMiss()); + } + + @Benchmark + public boolean support_hit(Cursor cursor) { + return StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextHit()) >= 0; + } + + @Benchmark + public boolean support_miss(Cursor cursor) { + return StringIndex.EmbeddingSupport.indexOf(SI_HASHES, SI_NAMES, cursor.nextMiss()) >= 0; + } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java new file mode 100644 index 00000000000..c527cb21a54 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/StringIndexSwitchBenchmark.java @@ -0,0 +1,300 @@ +package datadog.trace.util; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.CompilerControl; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * The third {@link StringIndex} use case: replacing a {@code switch} over interned {@code String} + * literals that maps a key to a small {@code int} id (exactly what {@code TagInterceptor} does to + * decide whether/how to intercept a tag). Both forms resolve a key to an id, 0 == "not found". + * + *

    Compared: + * + *

      + *
    • {@code switch} — a hand-written {@code switch(key)} over the literals ({@code hashCode} + * switch + {@code equals}), {@code default} returns 0. + *
    • {@code stringIndex} — {@code IDS[EmbeddingSupport.indexOf(HASHES, NAMES, key)]} over {@code + * static final} arrays (a miss returns 0), the folded-constant hot path. + *
    + * + *

    What this measures: two axes. A prior investigation found the {@code TagInterceptor} + * switch wasn't being inlined / specialized into its hot caller. So each form is measured across + * (a) inlining — {@code _inlined} vs {@code _noinline} (a real call, {@code TagInterceptor}'s + * actual regime) via {@link CompilerControl} — and (b) key shape — a constant key vs a runtime, + * varied key. The results (below) land the teaching point: the dominant axis is + * key-constancy, not inlining. At steady state the inline-vs-not gap is small for both + * forms; what sinks the switch is a runtime, varied key (it can't specialize), while the + * StringIndex {@code EmbeddingSupport} path stays flat across both axes — so the win is largest + * exactly where {@code TagInterceptor} lives. + * + *

    The {@code _inlined} and {@code _noinline} helpers carry duplicate bodies on purpose: that's + * the only way to pin each form's inlining decision independently. + * + *

    {@code @Threads(8)}; read-only, so no store dilutes the signal. Hit keys are the interned + * literals (the {@code ==} fast path StringIndex and the switch both get); misses are distinct and + * never present. Run via {@code -Pjmh.includes=StringIndexSwitchBenchmark} (add {@code -prof gc} — + * should be ~0 B/op both ways; this proves throughput, not allocation). + * + *

    JDK 17 results (Apple M1, quiet machine, {@code @Fork(5)}, {@code @Threads(8)}; M ops/s, + * ±1–5%): + * + *

    {@code
    + * key             switch (inl / noinl)   stringIndex (inl / noinl)
    + * const            2778 / 2769            2047 / 2035
    + * hit  (runtime)   1161 / 1166            2147 / 2152
    + * miss             2083 / 2050            2546 / 2539
    + * }
    + * + *

    Two takeaways: + * + *

      + *
    • The string switch only matches StringIndex in the constant-key corner + * (~2.7B): there the JIT specializes the switch to the single known key — and the {@code + * const} arms show it does so even across a {@code DONT_INLINE} boundary (profile-driven, not + * const-prop-through-inline). Production tags are runtime-varied, so that corner never + * occurs. + *
    • In the realistic regime — a runtime, varied hit key, exactly {@code TagInterceptor} + * — the switch falls to ~1.16B while StringIndex holds ~2.15B (~1.85x). StringIndex is + * flat (~2.0–2.5B) across inline/not-inline and key shape: its throughput doesn't + * depend on the JIT's inlining decisions, which is the whole point. (Misses short-circuit for + * both; StringIndex still ~1.2x.) + *
    + * + *

    So the {@code const} arm is the control: it exposes the switch's "fast" as a single-key + * specialization artifact — drop the constant and the switch is ~half StringIndex's throughput. + */ +@Fork(5) // matches the documented @Fork(5) numbers; the switch's const-key arm is profile-bimodal +@Warmup(iterations = 2) +@Measurement(iterations = 3) +@Threads(8) +@State(Scope.Benchmark) +public class StringIndexSwitchBenchmark { + static final String[] KEYS = { + "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", + "india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa" + }; + + // A compile-time-constant hit key. javac inlines it, so the JIT can constant-propagate it into an + // inlined switch and fold the whole switch away -- the switch's theoretical ceiling. The const_* + // arms pair this with INLINE vs DONT_INLINE to show that ceiling only materializes when the call + // ALSO inlines: across a DONT_INLINE boundary the constant can't propagate in, so the switch runs + // in full. TagInterceptor's real regime is a runtime tag through a non-inlined call -- neither + // holds -- which is why StringIndex wins where it counts. + static final String CONST_KEY = "mike"; + + /** Distinct String instances that are never present, for the miss path. */ + static final String[] MISSES = newMisses(); + + static String[] newMisses() { + String[] misses = new String[KEYS.length * 2]; + for (int i = 0; i < misses.length; ++i) { + misses[i] = "dne-" + i; + } + return misses; + } + + // StringIndex placed arrays + slot-aligned ids, pulled into static final fields so the JIT folds + // the refs to constants (the hot path StringIndex recommends). IDS[slot] is the 1-based id; + // empty slots stay 0, which doubles as the "not found" sentinel. + static final int[] HASHES; + static final String[] NAMES; + static final int[] IDS; + + static { + StringIndex.Data data = StringIndex.EmbeddingSupport.create(KEYS); + HASHES = data.hashes; + NAMES = data.names; + IDS = new int[HASHES.length]; + for (int i = 0; i < KEYS.length; ++i) { + IDS[StringIndex.EmbeddingSupport.indexOf(HASHES, NAMES, KEYS[i])] = + i + 1; // 1-based; 0 = not found + } + } + + /** Per-thread cursors so threads don't contend on a shared index under {@code @Threads(8)}. */ + @State(Scope.Thread) + public static class Cursor { + int hit = 0; + int miss = 0; + + String nextHit() { + int i = hit + 1; + if (i >= KEYS.length) { + i = 0; + } + hit = i; + return KEYS[i]; + } + + String nextMiss() { + int i = miss + 1; + if (i >= MISSES.length) { + i = 0; + } + miss = i; + return MISSES[i]; + } + } + + @CompilerControl(CompilerControl.Mode.INLINE) + static int switchInline(String key) { + switch (key) { + case "alpha": + return 1; + case "bravo": + return 2; + case "charlie": + return 3; + case "delta": + return 4; + case "echo": + return 5; + case "foxtrot": + return 6; + case "golf": + return 7; + case "hotel": + return 8; + case "india": + return 9; + case "juliet": + return 10; + case "kilo": + return 11; + case "lima": + return 12; + case "mike": + return 13; + case "november": + return 14; + case "oscar": + return 15; + case "papa": + return 16; + default: + return 0; + } + } + + // Duplicate body, pinned non-inlinable -- TagInterceptor's actual call regime. + @CompilerControl(CompilerControl.Mode.DONT_INLINE) + static int switchNoInline(String key) { + switch (key) { + case "alpha": + return 1; + case "bravo": + return 2; + case "charlie": + return 3; + case "delta": + return 4; + case "echo": + return 5; + case "foxtrot": + return 6; + case "golf": + return 7; + case "hotel": + return 8; + case "india": + return 9; + case "juliet": + return 10; + case "kilo": + return 11; + case "lima": + return 12; + case "mike": + return 13; + case "november": + return 14; + case "oscar": + return 15; + case "papa": + return 16; + default: + return 0; + } + } + + @CompilerControl(CompilerControl.Mode.INLINE) + static int indexInline(String key) { + int slot = StringIndex.EmbeddingSupport.indexOf(HASHES, NAMES, key); + return slot >= 0 ? IDS[slot] : 0; + } + + @CompilerControl(CompilerControl.Mode.DONT_INLINE) + static int indexNoInline(String key) { + int slot = StringIndex.EmbeddingSupport.indexOf(HASHES, NAMES, key); + return slot >= 0 ? IDS[slot] : 0; + } + + @Benchmark + public int switch_hit_inlined(Cursor cursor) { + return switchInline(cursor.nextHit()); + } + + @Benchmark + public int switch_miss_inlined(Cursor cursor) { + return switchInline(cursor.nextMiss()); + } + + @Benchmark + public int switch_hit_noinline(Cursor cursor) { + return switchNoInline(cursor.nextHit()); + } + + @Benchmark + public int switch_miss_noinline(Cursor cursor) { + return switchNoInline(cursor.nextMiss()); + } + + @Benchmark + public int stringIndex_hit_inlined(Cursor cursor) { + return indexInline(cursor.nextHit()); + } + + @Benchmark + public int stringIndex_miss_inlined(Cursor cursor) { + return indexInline(cursor.nextMiss()); + } + + @Benchmark + public int stringIndex_hit_noinline(Cursor cursor) { + return indexNoInline(cursor.nextHit()); + } + + @Benchmark + public int stringIndex_miss_noinline(Cursor cursor) { + return indexNoInline(cursor.nextMiss()); + } + + // --- constant key: the switch's best case (const-propagated). Inlined -> folds away; not-inlined + // -> the constant can't cross the boundary, so the switch runs in full. --- + + @Benchmark + public int switch_const_inlined() { + return switchInline(CONST_KEY); + } + + @Benchmark + public int switch_const_noinline() { + return switchNoInline(CONST_KEY); + } + + @Benchmark + public int stringIndex_const_inlined() { + return indexInline(CONST_KEY); + } + + @Benchmark + public int stringIndex_const_noinline() { + return indexNoInline(CONST_KEY); + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/TagMapReadThroughBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/TagMapReadThroughBenchmark.java new file mode 100644 index 00000000000..ed8768ef1cf --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/TagMapReadThroughBenchmark.java @@ -0,0 +1,84 @@ +package datadog.trace.util; + +import datadog.trace.api.TagMap; +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; + +/** + * Models span-build tag assembly with vs without read-through of the shared trace-level bundle. + * + *

      + *
    • copyDown — today's path: {@code putAll} the (frozen) trace-level bundle into the + * fresh span map, then set the span-specific tags. {@code putAll}-into-empty shares the + * frozen entry references (bucket-clone), so this does NOT allocate new Entry objects for the + * trace tags — its cost is cloned {@code BucketGroup}s plus the collisions caused by the + * trace tags sharing the local buckets with the span tags. + *
    • readThrough — attach the frozen bundle as a read-through parent; only the + * span-specific tags are stored locally. + *
    + * + *

    Run with {@code -prof gc}; the B/op delta is the per-span allocation read-through saves. Both + * arms set the same span tags, so the delta isolates the trace-bundle handling. {@code + * traceTagCount} sweeps the bundle size — the win scales with it (more trace tags → more cloned + * BucketGroups and local collisions avoided). {@code traceTagCount = 7} ≈ a realistic + * mergedTracerTags (env, version, language, runtime-id, a propagation tag, a couple global tags). + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Warmup(iterations = 5, time = 2) +@Measurement(iterations = 5, time = 2) +@Fork(3) +@Threads(8) +public class TagMapReadThroughBenchmark { + + @Param({"3", "7", "15"}) + int traceTagCount; + + private TagMap traceTags; + + @Setup(Level.Trial) + public void setup() { + TagMap m = TagMap.create(Math.max(16, traceTagCount * 2)); + for (int i = 0; i < traceTagCount; i++) { + m.set("_dd.trace.tag." + i, "trace-value-" + i); + } + this.traceTags = m.freeze(); + } + + @Benchmark + public TagMap copyDown() { + TagMap m = TagMap.create(16); + m.putAll(traceTags); // putAll-into-empty: shares frozen entries, clones BucketGroups + setSpanTags(m); + return m; + } + + @Benchmark + public TagMap readThrough() { + // no copy; trace tags read through the shared frozen parent (fixed at construction) + TagMap m = TagMap.createFromParent(traceTags); + setSpanTags(m); + return m; + } + + private static void setSpanTags(TagMap m) { + m.set("http.method", "GET"); + m.set("http.url", "/api/checkout/cart"); + m.set("component", "spring-web-controller"); + m.set("span.kind", "server"); + m.set("http.status_code", 200); + } +} 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 bc7b0b904af..75e618e514b 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -1413,6 +1413,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; @@ -3301,6 +3302,8 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) this.spanBuilderReuseEnabled = configProvider.getBoolean(GeneralConfig.SPAN_BUILDER_REUSE_ENABLED, true); + this.traceDenseTagsEnabled = + configProvider.getBoolean(TracerConfig.TRACE_DENSE_TAGS_ENABLED, false); this.tagNameUtf8CacheSize = Math.max(configProvider.getInteger(GeneralConfig.TAG_NAME_UTF8_CACHE_SIZE, 128), 0); this.tagValueUtf8CacheSize = @@ -5153,6 +5156,10 @@ public boolean isSpanBuilderReuseEnabled() { return spanBuilderReuseEnabled; } + public boolean isTraceDenseTagsEnabled() { + return traceDenseTagsEnabled; + } + public int getTagNameUtf8CacheSize() { return tagNameUtf8CacheSize; } diff --git a/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java new file mode 100644 index 00000000000..925e01c13a5 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/KnownTagCodec.java @@ -0,0 +1,300 @@ +package datadog.trace.api; + +/** + * Registry for generated tag ID ↔ name resolution. The code generator populates this at tracer init + * via {@link #register(Resolver)}. Once registered, HotSpot CHA devirtualizes and inlines the + * resolver's switch, making {@link #nameOf}/{@link #keyOf} effectively zero-overhead. + */ +public final class KnownTagCodec { + // Plain (non-volatile) fast-path flag: false until a mapping-bearing resolver is registered (it + // stays false when the codec freezes as the empty NoKnownTagCodec). A plain read is free and + // hoistable, unlike a volatile read of `resolver` (costly on weak memory models such as ARM). A + // stale `false` is benign — callers treat the tag as unknown and use the hash buckets, which is + // correct, just unoptimized; the next read after publication takes the slot path. + private static boolean active; + + // The installed codec. There is always conceptually a codec: either a real resolver (via + // register, at tracer init) or the empty NoKnownTagCodec, lazily installed on first use if + // nothing + // was registered. Resolved exactly once, then LOCKED — so a map can never be built half-bucketed + // then half-dense by a late registration. + private static volatile Resolver resolver; + + // True once `resolver` is resolved (real via register, or lazy NoKnownTagCodec). Cold-path only. + private static volatile boolean locked; + + /** Fast-path gate: true once a mapping-bearing resolver has been registered. */ + public static boolean isActive() { + return active; + } + + /* + * tagId bit layout: [63 intercepted] [62-48 globalSerial (15 bits)] [47-32 slot (16 bits)] [31-0 + * reserved, zero]. Bit 63 (the sign bit) marks a tag the tag interceptor must see, so the check is + * a single {@code tagId < 0}. globalSerial is globally unique per known tag. The middle 16 bits + * carry the tag's SLOT: one globally stable coordinate assigned by graph-coloring the tag + * co-occurrence graph (the resolved tag set of each concrete span type, plus the trace-level + * tier, is a clique). Co-occurring tags always get distinct slots; slots are reused only between + * tags that never appear together, so slotCount stays bounded by the largest clique (≤ 64) and + * fits one {@code long} occupancy mask — the dense store's single-tier presence fast path (see + * {@link TagMap}). The low 32 bits are unused for known ids (the whole id is fully determined by + * serial + slot, so the generator can emit a literal). The low 32 bits are being carved for + * cross-cutting flags; bit 2 is the trace/span LEVEL bit (set ⟹ trace-level), and bits 1-0 are + * reserved for the dd/otel applicability flags that land with increment 1. The level bit lets + * read-through skip the shadow check across the trace/span boundary — trace and span tags reuse + * the same slots, so occupancy alone can't tell them apart, but a span map (no trace-level tags) + * can never shadow a trace-level ancestor entry (see {@link TagMap}). Unknown (string-only) custom + * tags are NOT known ids — they key off {@code TagMap.Entry#_hash(name)} in their own bucket path + * and never enter here. + */ + public static int serialNum(long tagId) { + return (int) ((tagId >>> 48) & 0x7FFF); + } + + /** + * Flag bit (the sign bit) marking a tag the tag interceptor must process — reserved tags AND + * intercepted-but-stored tags (e.g. http.method, which the interceptor side-effects and also + * stores). Encoded in the id so {@code DDSpanContext.setTag(long)} can route with a single sign + * test ({@link #isIntercepted}) instead of resolving the name. Non-intercepted tags (peer.*, + * base.service, …) leave it clear and take the fast store path. Must agree with the interceptor's + * name-based {@code needsIntercept} for every assigned id. + */ + public static final long INTERCEPTED = Long.MIN_VALUE; // 1L << 63 + + /** True if the tagId is flagged for tag-interceptor processing. */ + public static boolean isIntercepted(long tagId) { + return tagId < 0L; + } + + /** Returns the tagId with the {@link #INTERCEPTED} flag set. */ + public static long intercepted(long tagId) { + return tagId | INTERCEPTED; + } + + /** + * Trace/span LEVEL bit (low-32 carve, bit 2). Set marks a trace-level tag (lives on the + * TraceSegment's own TagMap); clear marks a span-level tag. Trace and span tags reuse the same + * coloring slots, so this bit is what lets read-through tell the two levels apart — a span map + * (no trace-level tags) can never shadow a trace-level ancestor entry, so its shadow check is + * skipped (see {@link TagMap#parentDenseVisible}). + */ + public static final long LEVEL_TRACE = 1L << 2; + + /** True if the tagId names a trace-level tag. */ + public static boolean isTraceLevel(long tagId) { + return (tagId & LEVEL_TRACE) != 0L; + } + + /** Returns the tagId with the {@link #LEVEL_TRACE} flag set. */ + public static long traceLevel(long tagId) { + return tagId | LEVEL_TRACE; + } + + // The middle 16 bits [47-32] hold the tag's SLOT: one globally stable coordinate from graph + // coloring the co-occurrence graph. Co-occurring tags get distinct slots and slotCount stays + // bounded by the largest clique (<= 64), so the dense store's presence fast path is a single + // occupancy long (1L << slot); a clear bit proves the tag absent and enables an O(1) append. See + // TagMap's dense-store fast path. + static final int SLOT_SHIFT = 32; + static final int SLOT_MASK = 0xFFFF; // 16 bits + + /** + * The tag's slot: its globally stable coloring coordinate, or {@link #NO_SLOT} when it has none + * (reserved or deliberately bucket-only). Drives the dense store's single occupancy mask. + */ + public static int slot(long tagId) { + return (int) ((tagId >>> SLOT_SHIFT) & SLOT_MASK); + } + + /** + * globalSerial partition. {@code [1, FIRST_STORED_SERIAL)} is the RESERVED tier and {@code + * [FIRST_STORED_SERIAL, ..]} is the STORED tier; {@code globalSerial == 0} means unknown / + * string-only. Both core and the code generator must agree on this boundary. + * + *

    Reserved is the shared mechanism: the tracer reserves the key and handles it itself + * instead of putting it in the TagMap. It says nothing about whether a value exists — that splits + * into two kinds (the {@code kind:} in the overlay): + * + *

      + *
    • structural — the value does exist, it just lives in a first-class + * span/trace field (service, resource.name, error, span.type, origin), not the tag map. + *
    • directive — there is no stored value; the key is a command that triggers + * trace behavior (sampling.priority, manual.keep, measured). + *
    + * + * "virtual" over-claims non-existence (wrong for structural) and "built-in" over-claims existence + * (wrong for directive), so the tier is named for the mechanism they share: reserved. These are + * hand-assigned in the overlay. Stored tags are the generated convention tags that ARE put + * in the map (slotted/bucketed). + */ + public static final int FIRST_STORED_SERIAL = 256; + + /** True if the tagId names a reserved (structural/directive) tag — handled, not stored. */ + public static boolean isReserved(long tagId) { + int serialNum = serialNum(tagId); + return serialNum > 0 && serialNum < FIRST_STORED_SERIAL; + } + + /** True if the tagId names a generated, map-stored (slotted/bucketed) tag. */ + public static boolean isStored(long tagId) { + return serialNum(tagId) >= FIRST_STORED_SERIAL; + } + + /** + * Dense-store routing gate, decoupled from name resolution. The {@link Resolver} is registered + * unconditionally at tracer init (so {@code keyOf}/{@code nameOf} — and OTel name mapping — + * always work); this flag, captured once from {@code trace.experimental.dense.tags.enabled}, + * separately decides whether known tags actually take the dense store. As a {@code static final} + * it constant-folds, so the dense branches in {@link TagMap} dead-code-eliminate when off. + */ + public static final boolean DENSE_STORE = Config.get().isTraceDenseTagsEnabled(); + + /** + * True iff the tagId should route to the dense store: it names a stored tag AND the dense store + * is enabled. This is the single predicate {@link TagMap} branches on — {@link #isStored} alone + * is layout identity, independent of whether dense routing is switched on. + */ + public static boolean routesToDense(long tagId) { + return DENSE_STORE && isStored(tagId); + } + + /** + * Sentinel {@code slot} meaning "no positional slot". It is the maximum value the 16-bit slot + * field can hold, so it always compares {@code >= slotCount()} and routes to the hash buckets + * rather than the fast positional array. Two kinds of tagId use it: + * + *
      + *
    • Reserved tags ({@code globalSerial < FIRST_STORED_SERIAL}) — not stored at all; the + * sentinel just guarantees an incidental store never lands in a slot. + *
    • Unslotted stored tags ({@code globalSerial >= FIRST_STORED_SERIAL}) — "low-priority" tags + * that get a stable id (and so {@code keyOf}/{@code nameOf} unification with their string + * form) but are deliberately not given a slot, so they live in the buckets. {@code + * getEntry(long)} for these resolves the name and rehashes — the cost of not owning a slot. + *
    + */ + public static final int NO_SLOT = SLOT_MASK; // slot all-ones sentinel (16 bits) + + /** + * True if the tagId names a stored tag that deliberately has no positional slot (bucket-only). + */ + public static boolean isUnslotted(long tagId) { + return isStored(tagId) && slot(tagId) == NO_SLOT; + } + + /** + * Builds a tagId from its {@code serialNum} (globally unique per known tag) and {@code slot} (its + * coloring coordinate, or {@link #NO_SLOT}). The low 32 bits are zero, so the id is fully + * determined by these parts — the generator emits it as a literal. Inverse of {@link + * #serialNum}/{@link #slot}. Intended for the code generator and tests. + */ + public static long makeTagId(int serialNum, int slot) { + return ((long) serialNum << 48) | ((long) (slot & SLOT_MASK) << SLOT_SHIFT); + } + + /** + * Builds a tagId with no positional slot ({@code slot == }{@link #NO_SLOT}). Use for reserved + * tags and for "low-priority" stored tags that get a stable id but are intentionally kept out of + * the fast slot array (they route to the hash buckets). See {@link #NO_SLOT}. + */ + public static long makeTagId(int serialNum) { + return makeTagId(serialNum, NO_SLOT); + } + + // Number of positional slots in the global layout = (max stored fieldPos) + 1, declared by the + // registered provider. Captured once at registration and read as a dynamic constant; TagMap sizes + // its knownEntries array to exactly this rather than a hardcoded max. 0 when no resolver. + private static int slotCount; + + /** Slot count of the registered provider (max stored fieldPos + 1); 0 if none. */ + public static int slotCount() { + return slotCount; + } + + public interface Resolver { + String nameOf(long tagId); + + long keyOf(String name); + + /** Number of positional slots this provider uses: (max stored fieldPos) + 1. */ + int slotCount(); + } + + /** + * Empty null-object codec: no name↔id mappings, no slots. Installed lazily on first use when + * nothing was registered, so the codec is always present. Its behavior is byte-identical to the + * pre-registry world — {@code keyOf} returns 0, {@code nameOf} returns null, every tag is unknown + * and takes the hash buckets. + */ + private static final class NoKnownTagCodec implements Resolver { + static final NoKnownTagCodec INSTANCE = new NoKnownTagCodec(); + + @Override + public String nameOf(long tagId) { + return null; + } + + @Override + public long keyOf(String name) { + return 0L; + } + + @Override + public int slotCount() { + return 0; + } + } + + // active/slotCount are plain by design: written once here at tracer-init registration (before any + // span processing) and read plain on the hot path. A stale read is benign — the tag is treated as + // unknown and takes the hash-bucket path — so plain reads are deliberately preferred over a + // costly + // volatile read on weak memory models. + public static synchronized void register(Resolver resolver) { + if (resolver == null) { + throw new NullPointerException("resolver"); + } + if (locked) { + if (KnownTagCodec.resolver == resolver) { + return; // idempotent: the same resolver may be registered again (e.g. repeated init()) + } + throw new IllegalStateException( + "KnownTagCodec is already locked; a resolver cannot be registered after first use"); + } + KnownTagCodec.resolver = resolver; // volatile write publishes the resolver + KnownTagCodec.slotCount = resolver.slotCount(); + KnownTagCodec.locked = true; + KnownTagCodec.active = true; // plain write; readers re-read resolver volatile anyway + } + + // Freeze the codec as the empty NoKnownTagCodec when nothing was registered by first use. Keeps + // `active` false (No has no mappings) so the hot path stays a plain-boolean short-circuit. + private static synchronized void freezeAsNoCodec() { + if (locked) { + return; + } + KnownTagCodec.resolver = NoKnownTagCodec.INSTANCE; + KnownTagCodec.locked = true; + } + + public static String nameOf(long tagId) { + if (active) { + return resolver.nameOf(tagId); + } + if (!locked) { + freezeAsNoCodec(); + } + return null; + } + + public static long keyOf(String name) { + if (active) { + return resolver.keyOf(name); + } + if (!locked) { + freezeAsNoCodec(); + } + return 0L; + } + + private KnownTagCodec() {} +} diff --git a/internal-api/src/main/java/datadog/trace/api/KnownTags.java b/internal-api/src/main/java/datadog/trace/api/KnownTags.java new file mode 100644 index 00000000000..e8646593297 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/KnownTags.java @@ -0,0 +1,575 @@ +package datadog.trace.api; + +import datadog.trace.util.StringIndex; + +// GENERATED by the tag-registry code generator (dd-trace-java.tag-registry-generator). +// DO NOT EDIT. Source: tag-conventions.yaml + tag-conventions.java.yaml. +public final class KnownTags { + static final int SLOT_COUNT = 16; + + // ---- reserved (routed to span fields or directives; not stored) ---- + public static final String ERROR_NAME = "error"; + public static final long ERROR_ID = 0x8001FFFF00000000L; + // makeTagId(serial=1, slot=NO_SLOT) + intercepted [structural -> error] + + public static final String SERVICE_NAME = "service"; + public static final long SERVICE_ID = 0x8002FFFF00000000L; + // makeTagId(serial=2, slot=NO_SLOT) + intercepted [structural -> service] + + public static final String RESOURCE_NAME = "resource.name"; + public static final long RESOURCE_NAME_ID = 0x8003FFFF00000000L; + // makeTagId(serial=3, slot=NO_SLOT) + intercepted [structural -> resource] + + public static final String SPAN_TYPE_NAME = "span.type"; + public static final long SPAN_TYPE_ID = 0x8004FFFF00000000L; + // makeTagId(serial=4, slot=NO_SLOT) + intercepted [structural -> type] + + public static final String ORIGIN_NAME = "origin"; + public static final long ORIGIN_ID = 0x8005FFFF00000000L; + // makeTagId(serial=5, slot=NO_SLOT) + intercepted [structural -> origin] + + public static final String SAMPLING_PRIORITY_NAME = "sampling.priority"; + public static final long SAMPLING_PRIORITY_ID = 0x8006FFFF00000000L; + // makeTagId(serial=6, slot=NO_SLOT) + intercepted [directive] + + public static final String MANUAL_KEEP_NAME = "manual.keep"; + public static final long MANUAL_KEEP_ID = 0x8007FFFF00000000L; + // makeTagId(serial=7, slot=NO_SLOT) + intercepted [directive] + + public static final String MANUAL_DROP_NAME = "manual.drop"; + public static final long MANUAL_DROP_ID = 0x8008FFFF00000000L; + // makeTagId(serial=8, slot=NO_SLOT) + intercepted [directive] + + public static final String MEASURED_NAME = "measured"; + public static final long MEASURED_ID = 0x8009FFFF00000000L; + // makeTagId(serial=9, slot=NO_SLOT) + intercepted [directive] + + public static final String ANALYTICS_SAMPLE_RATE_NAME = "analytics.sample_rate"; + public static final long ANALYTICS_SAMPLE_RATE_ID = 0x800AFFFF00000000L; + // makeTagId(serial=10, slot=NO_SLOT) + intercepted [directive] + + // ---- stored (dense colored slot, or bucketed when slot=NO_SLOT) ---- + public static final String DD_APPSEC_ENABLED_NAME = "_dd.appsec.enabled"; + public static final long DD_APPSEC_ENABLED_ID = 0x0100000000000004L; + // makeTagId(serial=256, slot=0) + trace-level + + public static final String DD_BASE_SERVICE_NAME = "_dd.base_service"; + public static final long DD_BASE_SERVICE_ID = 0x0101000100000004L; + // makeTagId(serial=257, slot=1) + trace-level + + public static final String DD_CIVISIBILITY_ENABLED_NAME = "_dd.civisibility.enabled"; + public static final long DD_CIVISIBILITY_ENABLED_ID = 0x0102000200000004L; + // makeTagId(serial=258, slot=2) + trace-level + + public static final String DD_DJM_ENABLED_NAME = "_dd.djm.enabled"; + public static final long DD_DJM_ENABLED_ID = 0x0103000300000004L; + // makeTagId(serial=259, slot=3) + trace-level + + public static final String DD_DSM_ENABLED_NAME = "_dd.dsm.enabled"; + public static final long DD_DSM_ENABLED_ID = 0x0104000400000004L; + // makeTagId(serial=260, slot=4) + trace-level + + public static final String DD_GIT_COMMIT_SHA_NAME = "_dd.git.commit.sha"; + public static final long DD_GIT_COMMIT_SHA_ID = 0x0105000500000004L; + // makeTagId(serial=261, slot=5) + trace-level + + public static final String DD_GIT_REPOSITORY_URL_NAME = "_dd.git.repository_url"; + public static final long DD_GIT_REPOSITORY_URL_ID = 0x0106000600000004L; + // makeTagId(serial=262, slot=6) + trace-level + + public static final String DD_INTEGRATION_NAME = "_dd.integration"; + public static final long DD_INTEGRATION_ID = 0x0107000000000000L; + // makeTagId(serial=263, slot=0) + + public static final String DD_PARENT_ID_NAME = "_dd.parent_id"; + public static final long DD_PARENT_ID = 0x0108000100000000L; + // makeTagId(serial=264, slot=1) + + public static final String DD_PEER_SERVICE_REMAPPED_FROM_NAME = "_dd.peer.service.remapped_from"; + public static final long DD_PEER_SERVICE_REMAPPED_FROM_ID = 0x0109000700000000L; + // makeTagId(serial=265, slot=7) + + public static final String DD_PEER_SERVICE_SOURCE_NAME = "_dd.peer.service.source"; + public static final long DD_PEER_SERVICE_SOURCE_ID = 0x010A000800000000L; + // makeTagId(serial=266, slot=8) + + public static final String DD_PROFILING_ENABLED_NAME = "_dd.profiling.enabled"; + public static final long DD_PROFILING_ENABLED_ID = 0x010B000700000004L; + // makeTagId(serial=267, slot=7) + trace-level + + public static final String DD_SVC_SRC_NAME = "_dd.svc_src"; + public static final long DD_SVC_SRC_ID = 0x010CFFFF00000000L; + // makeTagId(serial=268, slot=NO_SLOT) + + public static final String DD_TRACER_HOST_NAME = "_dd.tracer_host"; + public static final long DD_TRACER_HOST_ID = 0x010D000800000004L; + // makeTagId(serial=269, slot=8) + trace-level + + public static final String COMPONENT_NAME = "component"; + public static final long COMPONENT_ID = 0x010E000200000000L; + // makeTagId(serial=270, slot=2) + + public static final String DB_INSTANCE_NAME = "db.instance"; + public static final long DB_INSTANCE_ID = 0x010F000900000000L; + // makeTagId(serial=271, slot=9) + + public static final String DB_OPERATION_NAME = "db.operation"; + public static final long DB_OPERATION_ID = 0x0110000A00000000L; + // makeTagId(serial=272, slot=10) + + public static final String DB_POOL_NAME = "db.pool.name"; + public static final long DB_POOL_NAME_ID = 0x0111FFFF00000000L; + // makeTagId(serial=273, slot=NO_SLOT) + + public static final String DB_STATEMENT_NAME = "db.statement"; + public static final long DB_STATEMENT_ID = 0x8112000B00000000L; + // makeTagId(serial=274, slot=11) + intercepted + + public static final String DB_TYPE_NAME = "db.type"; + public static final long DB_TYPE_ID = 0x0113000C00000000L; + // makeTagId(serial=275, slot=12) + + public static final String DB_USER_NAME = "db.user"; + public static final long DB_USER_ID = 0x0114000F00000000L; + // makeTagId(serial=276, slot=15) + + public static final String ENV_NAME = "env"; + public static final long ENV_ID = 0x0115000900000004L; + // makeTagId(serial=277, slot=9) + trace-level + + public static final String ERROR_MESSAGE_NAME = "error.message"; + public static final long ERROR_MESSAGE_ID = 0x0116000300000000L; + // makeTagId(serial=278, slot=3) + + public static final String ERROR_STACK_NAME = "error.stack"; + public static final long ERROR_STACK_ID = 0x0117000400000000L; + // makeTagId(serial=279, slot=4) + + public static final String ERROR_TYPE_NAME = "error.type"; + public static final long ERROR_TYPE_ID = 0x0118000500000000L; + // makeTagId(serial=280, slot=5) + + public static final String HTTP_HOSTNAME_NAME = "http.hostname"; + public static final long HTTP_HOSTNAME_ID = 0x0119000700000000L; + // makeTagId(serial=281, slot=7) + + public static final String HTTP_METHOD_NAME = "http.method"; + public static final long HTTP_METHOD_ID = 0x811A000900000000L; + // makeTagId(serial=282, slot=9) + intercepted + + public static final String HTTP_QUERY_STRING_NAME = "http.query.string"; + public static final long HTTP_QUERY_STRING_ID = 0x011B000800000000L; + // makeTagId(serial=283, slot=8) + + public static final String HTTP_RESEND_COUNT_NAME = "http.resend_count"; + public static final long HTTP_RESEND_COUNT_ID = 0x011C000F00000000L; + // makeTagId(serial=284, slot=15) + + public static final String HTTP_ROUTE_NAME = "http.route"; + public static final long HTTP_ROUTE_ID = 0x011D000D00000000L; + // makeTagId(serial=285, slot=13) + + public static final String HTTP_STATUS_CODE_NAME = "http.status_code"; + public static final long HTTP_STATUS_CODE_ID = 0x011E000A00000000L; + // makeTagId(serial=286, slot=10) + + public static final String HTTP_URL_NAME = "http.url"; + public static final long HTTP_URL_ID = 0x811F000B00000000L; + // makeTagId(serial=287, slot=11) + intercepted + + public static final String HTTP_USERAGENT_NAME = "http.useragent"; + public static final long HTTP_USERAGENT_ID = 0x0120000E00000000L; + // makeTagId(serial=288, slot=14) + + public static final String LANGUAGE_NAME = "language"; + public static final long LANGUAGE_ID = 0x0121000A00000004L; + // makeTagId(serial=289, slot=10) + trace-level + + public static final String NETWORK_PROTOCOL_VERSION_NAME = "network.protocol.version"; + public static final long NETWORK_PROTOCOL_VERSION_ID = 0x0122000C00000000L; + // makeTagId(serial=290, slot=12) + + public static final String PEER_HOSTNAME_NAME = "peer.hostname"; + public static final long PEER_HOSTNAME_ID = 0x0123000D00000000L; + // makeTagId(serial=291, slot=13) + + public static final String PEER_IPV4_NAME = "peer.ipv4"; + public static final long PEER_IPV4_ID = 0x0124FFFF00000000L; + // makeTagId(serial=292, slot=NO_SLOT) + + public static final String PEER_IPV6_NAME = "peer.ipv6"; + public static final long PEER_IPV6_ID = 0x0125FFFF00000000L; + // makeTagId(serial=293, slot=NO_SLOT) + + public static final String PEER_PORT_NAME = "peer.port"; + public static final long PEER_PORT_ID = 0x0126FFFF00000000L; + // makeTagId(serial=294, slot=NO_SLOT) + + public static final String PEER_SERVICE_NAME = "peer.service"; + public static final long PEER_SERVICE_ID = 0x8127000E00000000L; + // makeTagId(serial=295, slot=14) + intercepted + + public static final String RUNTIME_ID_NAME = "runtime-id"; + public static final long RUNTIME_ID = 0x0128000B00000004L; + // makeTagId(serial=296, slot=11) + trace-level + + public static final String SERVLET_CONTEXT_NAME = "servlet.context"; + public static final long SERVLET_CONTEXT_ID = 0x8129FFFF00000000L; + // makeTagId(serial=297, slot=NO_SLOT) + intercepted + + public static final String SERVLET_PATH_NAME = "servlet.path"; + public static final long SERVLET_PATH_ID = 0x012AFFFF00000000L; + // makeTagId(serial=298, slot=NO_SLOT) + + public static final String SPAN_KIND_NAME = "span.kind"; + public static final long SPAN_KIND_ID = 0x812B000600000000L; + // makeTagId(serial=299, slot=6) + intercepted + + public static final String VERSION_NAME = "version"; + public static final long VERSION_ID = 0x012C000C00000004L; + // makeTagId(serial=300, slot=12) + trace-level + + public static final String VIEW_NAME = "view.name"; + public static final long VIEW_NAME_ID = 0x012D000700000000L; + // makeTagId(serial=301, slot=7) + + // ---- serial numbers ---- + static final int ERROR_SERIAL_NUM = 1; + static final int SERVICE_SERIAL_NUM = 2; + static final int RESOURCE_NAME_SERIAL_NUM = 3; + static final int SPAN_TYPE_SERIAL_NUM = 4; + static final int ORIGIN_SERIAL_NUM = 5; + static final int SAMPLING_PRIORITY_SERIAL_NUM = 6; + static final int MANUAL_KEEP_SERIAL_NUM = 7; + static final int MANUAL_DROP_SERIAL_NUM = 8; + static final int MEASURED_SERIAL_NUM = 9; + static final int ANALYTICS_SAMPLE_RATE_SERIAL_NUM = 10; + static final int DD_APPSEC_ENABLED_SERIAL_NUM = 256; + static final int DD_BASE_SERVICE_SERIAL_NUM = 257; + static final int DD_CIVISIBILITY_ENABLED_SERIAL_NUM = 258; + static final int DD_DJM_ENABLED_SERIAL_NUM = 259; + static final int DD_DSM_ENABLED_SERIAL_NUM = 260; + static final int DD_GIT_COMMIT_SHA_SERIAL_NUM = 261; + static final int DD_GIT_REPOSITORY_URL_SERIAL_NUM = 262; + static final int DD_INTEGRATION_SERIAL_NUM = 263; + static final int DD_PARENT_ID_SERIAL_NUM = 264; + static final int DD_PEER_SERVICE_REMAPPED_FROM_SERIAL_NUM = 265; + static final int DD_PEER_SERVICE_SOURCE_SERIAL_NUM = 266; + static final int DD_PROFILING_ENABLED_SERIAL_NUM = 267; + static final int DD_SVC_SRC_SERIAL_NUM = 268; + static final int DD_TRACER_HOST_SERIAL_NUM = 269; + static final int COMPONENT_SERIAL_NUM = 270; + static final int DB_INSTANCE_SERIAL_NUM = 271; + static final int DB_OPERATION_SERIAL_NUM = 272; + static final int DB_POOL_NAME_SERIAL_NUM = 273; + static final int DB_STATEMENT_SERIAL_NUM = 274; + static final int DB_TYPE_SERIAL_NUM = 275; + static final int DB_USER_SERIAL_NUM = 276; + static final int ENV_SERIAL_NUM = 277; + static final int ERROR_MESSAGE_SERIAL_NUM = 278; + static final int ERROR_STACK_SERIAL_NUM = 279; + static final int ERROR_TYPE_SERIAL_NUM = 280; + static final int HTTP_HOSTNAME_SERIAL_NUM = 281; + static final int HTTP_METHOD_SERIAL_NUM = 282; + static final int HTTP_QUERY_STRING_SERIAL_NUM = 283; + static final int HTTP_RESEND_COUNT_SERIAL_NUM = 284; + static final int HTTP_ROUTE_SERIAL_NUM = 285; + static final int HTTP_STATUS_CODE_SERIAL_NUM = 286; + static final int HTTP_URL_SERIAL_NUM = 287; + static final int HTTP_USERAGENT_SERIAL_NUM = 288; + static final int LANGUAGE_SERIAL_NUM = 289; + static final int NETWORK_PROTOCOL_VERSION_SERIAL_NUM = 290; + static final int PEER_HOSTNAME_SERIAL_NUM = 291; + static final int PEER_IPV4_SERIAL_NUM = 292; + static final int PEER_IPV6_SERIAL_NUM = 293; + static final int PEER_PORT_SERIAL_NUM = 294; + static final int PEER_SERVICE_SERIAL_NUM = 295; + static final int RUNTIME_ID_SERIAL_NUM = 296; + static final int SERVLET_CONTEXT_SERIAL_NUM = 297; + static final int SERVLET_PATH_SERIAL_NUM = 298; + static final int SPAN_KIND_SERIAL_NUM = 299; + static final int VERSION_SERIAL_NUM = 300; + static final int VIEW_NAME_SERIAL_NUM = 301; + + private static final String[] KEYOF_NAMES = { + ERROR_NAME, + SERVICE_NAME, + RESOURCE_NAME, + SPAN_TYPE_NAME, + ORIGIN_NAME, + SAMPLING_PRIORITY_NAME, + MANUAL_KEEP_NAME, + MANUAL_DROP_NAME, + MEASURED_NAME, + ANALYTICS_SAMPLE_RATE_NAME, + DD_APPSEC_ENABLED_NAME, + DD_BASE_SERVICE_NAME, + DD_CIVISIBILITY_ENABLED_NAME, + DD_DJM_ENABLED_NAME, + DD_DSM_ENABLED_NAME, + DD_GIT_COMMIT_SHA_NAME, + DD_GIT_REPOSITORY_URL_NAME, + DD_INTEGRATION_NAME, + DD_PARENT_ID_NAME, + DD_PEER_SERVICE_REMAPPED_FROM_NAME, + DD_PEER_SERVICE_SOURCE_NAME, + DD_PROFILING_ENABLED_NAME, + DD_SVC_SRC_NAME, + DD_TRACER_HOST_NAME, + COMPONENT_NAME, + DB_INSTANCE_NAME, + DB_OPERATION_NAME, + DB_POOL_NAME, + DB_STATEMENT_NAME, + DB_TYPE_NAME, + DB_USER_NAME, + ENV_NAME, + ERROR_MESSAGE_NAME, + ERROR_STACK_NAME, + ERROR_TYPE_NAME, + HTTP_HOSTNAME_NAME, + HTTP_METHOD_NAME, + HTTP_QUERY_STRING_NAME, + HTTP_RESEND_COUNT_NAME, + HTTP_ROUTE_NAME, + HTTP_STATUS_CODE_NAME, + HTTP_URL_NAME, + HTTP_USERAGENT_NAME, + LANGUAGE_NAME, + NETWORK_PROTOCOL_VERSION_NAME, + PEER_HOSTNAME_NAME, + PEER_IPV4_NAME, + PEER_IPV6_NAME, + PEER_PORT_NAME, + PEER_SERVICE_NAME, + RUNTIME_ID_NAME, + SERVLET_CONTEXT_NAME, + SERVLET_PATH_NAME, + SPAN_KIND_NAME, + VERSION_NAME, + VIEW_NAME, + }; + private static final long[] KEYOF_VALUES = { + ERROR_ID, + SERVICE_ID, + RESOURCE_NAME_ID, + SPAN_TYPE_ID, + ORIGIN_ID, + SAMPLING_PRIORITY_ID, + MANUAL_KEEP_ID, + MANUAL_DROP_ID, + MEASURED_ID, + ANALYTICS_SAMPLE_RATE_ID, + DD_APPSEC_ENABLED_ID, + DD_BASE_SERVICE_ID, + DD_CIVISIBILITY_ENABLED_ID, + DD_DJM_ENABLED_ID, + DD_DSM_ENABLED_ID, + DD_GIT_COMMIT_SHA_ID, + DD_GIT_REPOSITORY_URL_ID, + DD_INTEGRATION_ID, + DD_PARENT_ID, + DD_PEER_SERVICE_REMAPPED_FROM_ID, + DD_PEER_SERVICE_SOURCE_ID, + DD_PROFILING_ENABLED_ID, + DD_SVC_SRC_ID, + DD_TRACER_HOST_ID, + COMPONENT_ID, + DB_INSTANCE_ID, + DB_OPERATION_ID, + DB_POOL_NAME_ID, + DB_STATEMENT_ID, + DB_TYPE_ID, + DB_USER_ID, + ENV_ID, + ERROR_MESSAGE_ID, + ERROR_STACK_ID, + ERROR_TYPE_ID, + HTTP_HOSTNAME_ID, + HTTP_METHOD_ID, + HTTP_QUERY_STRING_ID, + HTTP_RESEND_COUNT_ID, + HTTP_ROUTE_ID, + HTTP_STATUS_CODE_ID, + HTTP_URL_ID, + HTTP_USERAGENT_ID, + LANGUAGE_ID, + NETWORK_PROTOCOL_VERSION_ID, + PEER_HOSTNAME_ID, + PEER_IPV4_ID, + PEER_IPV6_ID, + PEER_PORT_ID, + PEER_SERVICE_ID, + RUNTIME_ID, + SERVLET_CONTEXT_ID, + SERVLET_PATH_ID, + SPAN_KIND_ID, + VERSION_ID, + VIEW_NAME_ID, + }; + private static final int[] KEYOF_HASHES; + private static final String[] KEYOF_KEYS; + private static final long[] KEYOF_IDS; + + static { + StringIndex.Data data = StringIndex.EmbeddingSupport.create(KEYOF_NAMES); + long[] ids = new long[data.names.length]; + for (int j = 0; j < KEYOF_NAMES.length; j++) { + ids[StringIndex.EmbeddingSupport.indexOf(data.hashes, data.names, KEYOF_NAMES[j])] = + KEYOF_VALUES[j]; + } + KEYOF_HASHES = data.hashes; + KEYOF_KEYS = data.names; + KEYOF_IDS = ids; + } + + static final KnownTagCodec.Resolver RESOLVER = + new KnownTagCodec.Resolver() { + @Override + public String nameOf(long tagId) { + switch (KnownTagCodec.serialNum(tagId)) { + case ERROR_SERIAL_NUM: + return ERROR_NAME; + case SERVICE_SERIAL_NUM: + return SERVICE_NAME; + case RESOURCE_NAME_SERIAL_NUM: + return RESOURCE_NAME; + case SPAN_TYPE_SERIAL_NUM: + return SPAN_TYPE_NAME; + case ORIGIN_SERIAL_NUM: + return ORIGIN_NAME; + case SAMPLING_PRIORITY_SERIAL_NUM: + return SAMPLING_PRIORITY_NAME; + case MANUAL_KEEP_SERIAL_NUM: + return MANUAL_KEEP_NAME; + case MANUAL_DROP_SERIAL_NUM: + return MANUAL_DROP_NAME; + case MEASURED_SERIAL_NUM: + return MEASURED_NAME; + case ANALYTICS_SAMPLE_RATE_SERIAL_NUM: + return ANALYTICS_SAMPLE_RATE_NAME; + case DD_APPSEC_ENABLED_SERIAL_NUM: + return DD_APPSEC_ENABLED_NAME; + case DD_BASE_SERVICE_SERIAL_NUM: + return DD_BASE_SERVICE_NAME; + case DD_CIVISIBILITY_ENABLED_SERIAL_NUM: + return DD_CIVISIBILITY_ENABLED_NAME; + case DD_DJM_ENABLED_SERIAL_NUM: + return DD_DJM_ENABLED_NAME; + case DD_DSM_ENABLED_SERIAL_NUM: + return DD_DSM_ENABLED_NAME; + case DD_GIT_COMMIT_SHA_SERIAL_NUM: + return DD_GIT_COMMIT_SHA_NAME; + case DD_GIT_REPOSITORY_URL_SERIAL_NUM: + return DD_GIT_REPOSITORY_URL_NAME; + case DD_INTEGRATION_SERIAL_NUM: + return DD_INTEGRATION_NAME; + case DD_PARENT_ID_SERIAL_NUM: + return DD_PARENT_ID_NAME; + case DD_PEER_SERVICE_REMAPPED_FROM_SERIAL_NUM: + return DD_PEER_SERVICE_REMAPPED_FROM_NAME; + case DD_PEER_SERVICE_SOURCE_SERIAL_NUM: + return DD_PEER_SERVICE_SOURCE_NAME; + case DD_PROFILING_ENABLED_SERIAL_NUM: + return DD_PROFILING_ENABLED_NAME; + case DD_SVC_SRC_SERIAL_NUM: + return DD_SVC_SRC_NAME; + case DD_TRACER_HOST_SERIAL_NUM: + return DD_TRACER_HOST_NAME; + case COMPONENT_SERIAL_NUM: + return COMPONENT_NAME; + case DB_INSTANCE_SERIAL_NUM: + return DB_INSTANCE_NAME; + case DB_OPERATION_SERIAL_NUM: + return DB_OPERATION_NAME; + case DB_POOL_NAME_SERIAL_NUM: + return DB_POOL_NAME; + case DB_STATEMENT_SERIAL_NUM: + return DB_STATEMENT_NAME; + case DB_TYPE_SERIAL_NUM: + return DB_TYPE_NAME; + case DB_USER_SERIAL_NUM: + return DB_USER_NAME; + case ENV_SERIAL_NUM: + return ENV_NAME; + case ERROR_MESSAGE_SERIAL_NUM: + return ERROR_MESSAGE_NAME; + case ERROR_STACK_SERIAL_NUM: + return ERROR_STACK_NAME; + case ERROR_TYPE_SERIAL_NUM: + return ERROR_TYPE_NAME; + case HTTP_HOSTNAME_SERIAL_NUM: + return HTTP_HOSTNAME_NAME; + case HTTP_METHOD_SERIAL_NUM: + return HTTP_METHOD_NAME; + case HTTP_QUERY_STRING_SERIAL_NUM: + return HTTP_QUERY_STRING_NAME; + case HTTP_RESEND_COUNT_SERIAL_NUM: + return HTTP_RESEND_COUNT_NAME; + case HTTP_ROUTE_SERIAL_NUM: + return HTTP_ROUTE_NAME; + case HTTP_STATUS_CODE_SERIAL_NUM: + return HTTP_STATUS_CODE_NAME; + case HTTP_URL_SERIAL_NUM: + return HTTP_URL_NAME; + case HTTP_USERAGENT_SERIAL_NUM: + return HTTP_USERAGENT_NAME; + case LANGUAGE_SERIAL_NUM: + return LANGUAGE_NAME; + case NETWORK_PROTOCOL_VERSION_SERIAL_NUM: + return NETWORK_PROTOCOL_VERSION_NAME; + case PEER_HOSTNAME_SERIAL_NUM: + return PEER_HOSTNAME_NAME; + case PEER_IPV4_SERIAL_NUM: + return PEER_IPV4_NAME; + case PEER_IPV6_SERIAL_NUM: + return PEER_IPV6_NAME; + case PEER_PORT_SERIAL_NUM: + return PEER_PORT_NAME; + case PEER_SERVICE_SERIAL_NUM: + return PEER_SERVICE_NAME; + case RUNTIME_ID_SERIAL_NUM: + return RUNTIME_ID_NAME; + case SERVLET_CONTEXT_SERIAL_NUM: + return SERVLET_CONTEXT_NAME; + case SERVLET_PATH_SERIAL_NUM: + return SERVLET_PATH_NAME; + case SPAN_KIND_SERIAL_NUM: + return SPAN_KIND_NAME; + case VERSION_SERIAL_NUM: + return VERSION_NAME; + case VIEW_NAME_SERIAL_NUM: + return VIEW_NAME; + default: + return null; + } + } + + @Override + public int slotCount() { + return SLOT_COUNT; + } + + @Override + public long keyOf(String name) { + int slot = StringIndex.EmbeddingSupport.indexOf(KEYOF_HASHES, KEYOF_KEYS, name); + return slot < 0 ? 0L : KEYOF_IDS[slot]; + } + }; + + static { + KnownTagCodec.register(RESOLVER); + } + + /** + * Forces resolver registration. Merely invoking this static method runs {@code } (which + * registers {@link #RESOLVER}), so calling it once at tracer init makes tag-id name resolution + * ({@code keyOf}/{@code nameOf}) live; idempotent. Whether known tags then take the dense store + * is a separate, const-folded decision ({@link KnownTagCodec#DENSE_STORE}). Until something + * references this class the registry stays dormant and {@code keyOf} returns 0, so tag storage is + * byte-identical to the bucket-only behavior. + */ + public static void init() {} + + private KnownTags() {} +} diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index 761320a2205..b7a5b62a58d 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -6,6 +6,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.Iterator; import java.util.Map; import java.util.NoSuchElementException; @@ -46,11 +47,9 @@ */ public final class TagMap implements Map, Iterable { /** Immutable empty TagMap - similar to {@link Collections#emptyMap()} */ - // Frozen view over a length-1 array: bucket masking needs a power-of-two array length (size 0 - // would fail with ArrayIndexOutOfBoundsException, size 1 works), and the private constructor - // reads - // no statics, so this is safe to build directly during TagMap's . - public static final TagMap EMPTY = new TagMap(new Object[1], 0); + // Frozen view over a power-of-two array; 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 << 4], 0); /** Creates a new mutable TagMap that contains the contents of map */ public static final TagMap fromMap(@Nonnull Map map) { @@ -76,6 +75,32 @@ public static final TagMap create(int size) { return new TagMap(); } + /** + * Creates a fresh, mutable TagMap that reads through to {@code parent} on local misses. The + * parent must be frozen and is fixed for the life of the returned map (no re-parenting), so + * read-through relies on a stable parent rather than an unenforced convention. Level-split phase + * 1. + * + *

    Parents may themselves have parents: reads and the bulk/union views both walk the full + * ancestor chain, nearest-level-wins, so a multi-level chain (e.g. baggage over trace tags) is a + * supported layering, not just a single frozen parent. + * + *

    An empty parent is dropped (treated as no parent): it contributes nothing to read through, + * and — being frozen — never will, so attaching it would only add read-through cost to every + * local miss/removal for no benefit. + */ + public static final TagMap createFromParent(TagMap parent) { + if (parent != null) { + if (!parent.frozen) { + throw new IllegalStateException("read-through parent must be frozen"); + } + if (parent.isDefinitelyEmpty()) { + parent = null; + } + } + return new TagMap(parent); + } + /** Creates a new TagMap.Ledger */ public static final Ledger ledger() { return new Ledger(); @@ -991,15 +1016,113 @@ public EntryChange next() { * However as a precaution if a BucketGroup becomes completely empty, then that BucketGroup will be * removed from the collision chain. */ - private final Object[] buckets; + + // Shared immutable empty buckets (all null, length 16). Every map points here until its first + // custom-tag write copies-on-write to a private array (materializeBuckets), so an all-known / + // known-heavy map (e.g. the trace-tier read-through parent) allocates ZERO buckets. Length is + // always 16, so reads need no null guard and read-through bucket alignment (hash & 15) holds. + private static final Object[] EMPTY_BUCKETS = new Object[1 << 4]; + + private Object[] buckets; private int size; private boolean frozen; + /** + * Dense known-tag store (dense-tagmap-design §5). Values for KNOWN tags (those {@link + * KnownTagCodec#keyOf} resolves to a stored id) live in these INSERTION-ORDERED parallel arrays + * with NO per-tag {@link Entry} object — the allocation win. Lazily allocated on the first + * known-tag write ({@code null} until then, so all-unknown maps pay nothing) and grown x2 from + * {@link #KNOWN_INIT_CAP}. Matched by globalSerial via a linear scan ({@link #knownIndexOf}); + * reads aren't hot, so O(knownCount) is fine and positional indexing is deferred. Dormant until a + * resolver is registered: {@code keyOf} returns 0, so nothing routes here and production is + * byte-identical. + * + *

    Disjoint from {@link #buckets} by construction: known-ness is global ({@code keyOf} is + * deterministic), so a known tag is ALWAYS dense and never bucketed, and vice-versa. That + * disjointness keeps read-through shadow checks within-region — an ancestor dense entry can only + * be shadowed by a nearer level's dense entry of the same id, an ancestor bucket entry only by a + * nearer level's bucket entry — so the bucket read-through chain walk is unchanged and the dense + * one mirrors it ({@link #parentDenseVisible}). + * + *

    {@link #size} counts bucket entries only; {@link #knownCount} counts dense entries; the + * local total is {@code size + knownCount}. + */ + private long[] knownIds; + + private Object[] knownValues; + private int knownCount; + + /** + * Single-tier presence filter over the dense store — the fast path that lets a definitely-absent + * known tag append in O(1) instead of paying the {@link #knownIndexOf} scan (the common per-build + * insert). Each tag's id carries a globally stable {@code slot} from graph coloring (see {@link + * KnownTagCodec}); a tag is present ONLY IF its slot bit is set in {@link #knownOccupancy}. A + * clear bit ⟹ definitely absent ⟹ skip the scan. + * + *

    Because co-occurring tags always get distinct slots (they form a clique in the coloring), + * slotCount is bounded by the largest clique (≤ 64) and every present tag of a well-formed map + * has its own bit — so one {@code long} is the whole filter, collapsing the earlier two-tier + * (group mask + field bloom) design to a single word. Disjoint occupancy across two maps ({@code + * (a.knownOccupancy & b.knownOccupancy) == 0}) proves nothing shadows across them, which the + * read-through shadow check exploits (see {@link #parentDenseVisible}). + * + *

    Superset semantics: bits are set on every add and NEVER cleared on remove (a stale bit only + * costs a scan, never a wrong answer), so correctness never depends on the slot→bit collision + * rate — only the fast-path hit rate does. Unslotted stored tags ({@link KnownTagCodec#NO_SLOT}) + * all fold onto one shared bit ({@code slot & 63}); the scan stays authoritative for them. + */ + private long knownOccupancy; + + /** + * Whether this map holds any trace-level known tag ({@link KnownTagCodec#isTraceLevel}). Trace + * and span tags reuse the same slots, so {@link #knownOccupancy} can't tell the two levels apart; + * this flag can. A span map leaves it {@code false}, which lets {@link #parentDenseVisible} skip + * the shadow check when enumerating a trace-level ancestor entry — a map with no trace-level tags + * cannot shadow one. Superset semantics like the occupancy mask: set on add, never cleared on + * remove (a stale {@code true} only costs a scan, never a wrong answer). + */ + private boolean knownTraceLevel; + + private static final int KNOWN_INIT_CAP = + 12; // generous per-type max stopgap; exact per-type sizing comes with the tag registry + + /** + * Optional frozen parent for read-through. When non-null, reads that miss the local buckets fall + * through to the parent chain, nearest-level-wins (a local entry shadows the parent's, a nearer + * ancestor shadows a farther one). Parents may themselves have parents, so this is a chain (e.g. + * baggage layered over trace tags). Must be frozen when attached, so it is safely shareable. Not + * final only so {@link #clear()} can detach it (to null); it is otherwise fixed at construction + * and never re-pointed. Package-visible so same-package tests can assert attach/detach directly. + */ + TagMap parent; + + /** + * Parent keys removed locally (read-through tombstones). Lazily allocated on the first such + * removal; {@code null} both means "no tombstones" and serves as the gate that keeps the hot + * paths untouched. Only meaningful when {@link #parent} != null. A tombstone stops read-through + * fall-through for its key, so a key removed from a child no longer reads through to the parent. + * Kept off the bucket structure deliberately — it is shape-agnostic (bare-Entry vs BucketGroup) + * and rare, so it costs a lazy allocation on removal rather than complicating the hot bucket + * code. + */ + private Set removedFromParent; + public TagMap() { - // needs to be a power of 2 for bucket masking calculation to work as intended - this.buckets = new Object[1 << 4]; + this((TagMap) null); + } + + /** + * Fresh mutable map that reads through to {@code parent} (may be null). The parent is set here at + * construction and never re-pointed (only detached to null by {@link #clear()}), so read-through + * optimizations can treat it as fixed. + */ + private TagMap(TagMap parent) { + // Start on the shared empty buckets; materializeBuckets() COWs to a private power-of-two array + // on the first custom-tag write. All-known maps never allocate buckets. + this.buckets = EMPTY_BUCKETS; this.size = 0; this.frozen = false; + this.parent = parent; } /** Used for inexpensive immutable */ @@ -1007,6 +1130,7 @@ private TagMap(Object[] buckets, int size) { this.buckets = buckets; this.size = size; this.frozen = true; + this.parent = null; } public boolean isOptimized() { @@ -1015,12 +1139,81 @@ public boolean isOptimized() { @Override public int size() { - return this.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 ? local : local + this.visibleParentCount(); + } + + /** + * Exact count of ancestor entries not shadowed by a nearer level or tombstoned (the read-through + * addition). Walks the ancestor chain iteratively, nearest-level-wins. + */ + private int visibleParentCount() { + int count = 0; + for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + // dense entries at this ancestor not shadowed/tombstoned by a nearer level + long[] ancestorIds = ancestor.knownIds; + int ancestorKnownCount = ancestor.knownCount; + for (int i = 0; i < ancestorKnownCount; ++i) { + if (this.parentDenseVisible(ancestorIds[i], ancestor)) count++; + } + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + if (parentBucket instanceof Entry) { + if (parentEntryVisible((Entry) parentBucket, ancestor)) count++; + } else if (parentBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) parentBucket; + curGroup != null; + curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry parentEntry = curGroup._entryAt(j); + if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) count++; + } + } + } + } + } + return count; } @Override public boolean isEmpty() { - return (this.size == 0); + // Exact (Map contract). Under read-through resolves the parent; prefer isDefinitelyEmpty(). + if (this.size != 0 || this.knownCount != 0) { + return false; + } + TagMap parent = this.parent; + if (parent == null) { + return true; + } + if (this.removedFromParent == null) { + // no local entries and no tombstones -> empty iff the whole ancestor chain is empty (nothing + // shadows it). Ancestors are frozen (no tombstones), so exact == definite here. + return parent.isDefinitelyEmpty(); + } + // size == 0 with tombstones (rare): empty iff every visible ancestor entry is tombstoned + return this.visibleParentCount() == 0; + } + + public boolean isDefinitelyEmpty() { + // Cheap: empty iff no level in the chain holds a local entry (ignores shadowing/tombstones). + for (TagMap level = this; level != null; level = level.parent) { + if (level.size != 0 || level.knownCount != 0) { + return false; + } + } + return true; + } + + public int estimateSize() { + // Upper bound: sum of every level's local size (buckets + dense), ignoring shadowing/removals. + int total = 0; + for (TagMap level = this; level != null; level = level.parent) { + total += level.size + level.knownCount; + } + return total; } @Deprecated @@ -1128,67 +1321,293 @@ public Set> entrySet() { } public Entry getEntry(String tag) { - Object[] thisBuckets = this.buckets; + Entry local = this.getLocalEntry(tag); + if (local != null) { + // Local entry shadows the parent (local-wins) — unchanged hot path. + return local; + } + // Read-through: miss locally, defer to the frozen parent. Single-parent in phase 1. + // The tombstone check lives only here, on the cold miss+parent path — the hot local hit above + // never touches it. + TagMap parent = this.parent; + if (parent == null) { + return null; + } + if (this.removedFromParent != null && this.removedFromParent.contains(tag)) { + return null; // tombstoned: removed locally, do not read through + } + return parent.getEntry(tag); + } + /** Looks up an entry in this map's own storage only (dense then buckets) — no read-through. */ + private Entry getLocalEntry(String tag) { + // Known tags live in the dense store; resolve identity and check there first. keyOf is a no-op + // (returns 0 -> isStored false) until a resolver is registered, so this is inert in production. + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L; + if (KnownTagCodec.isStored(id)) { + Object known = this.knownRawValue(id); + return known == null ? null : Entry.newAnyEntry(tag, known); + } + Object[] thisBuckets = this.buckets; int hash = TagMap.Entry._hash(tag); - int bucketIndex = hash & (thisBuckets.length - 1); + return findInBucket(thisBuckets[hash & (thisBuckets.length - 1)], hash, tag); + } - Object bucket = thisBuckets[bucketIndex]; - if (bucket == null) { - return null; - } else if (bucket instanceof Entry) { + /** + * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null). + */ + private static Entry findInBucket(Object bucket, int hash, String tag) { + if (bucket instanceof Entry) { Entry tagEntry = (Entry) bucket; - if (tagEntry.matches(tag)) return tagEntry; + return tagEntry.matches(tag) ? tagEntry : null; } else if (bucket instanceof BucketGroup) { - BucketGroup lastGroup = (BucketGroup) bucket; + return ((BucketGroup) bucket).findInChain(hash, tag); + } + return null; + } + + /** + * Whether an entry that lives at ancestor level {@code fromAncestor} is visible through this + * leaf: not shadowed and not tombstoned by any nearer level (the leaf, or a closer ancestor). + * Nearest-level-wins. This mirrors what {@link #getEntry(String)}'s recursion applies as it + * descends -- each nearer level contributes both its own local entries (shadowing) and its own + * read-through removals (tombstones). A non-leaf level can carry tombstones too: a map may remove + * an inherited key and then itself be frozen and reused as a parent. Exploits universal hashing — + * by {@code _hash} the only local entry that could shadow {@code parentEntry} at a level is in + * that level's same-index bucket, so each nearer level is probed at one bucket using {@code + * parentEntry}'s cached hash (no re-hash, no full-map probe). + */ + private boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) { + int hash = parentEntry.hash(); + String tag = parentEntry.tag; + for (TagMap nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + // tombstoned by a nearer level (its own read-through removal hides the deeper entry) + if (nearer.removedFromParent != null && nearer.removedFromParent.contains(tag)) { + return false; + } + // shadowed by a nearer level's local entry + Object[] nearerBuckets = nearer.buckets; + Object nearerBucket = nearerBuckets[hash & (nearerBuckets.length - 1)]; + if (findInBucket(nearerBucket, hash, tag) != null) { + return false; + } + } + return true; + } + + // ---- dense known-tag store (see the knownIds field doc) + // ---------------------------------------- + + /** + * Linear scan of the dense store for {@code tagId}, returning its index or -1. Ids are canonical + * (the only way one enters is {@link KnownTagCodec#keyOf} or a {@code KnownTags} constant, both + * canonical), so a full {@code long} compare is exact and cheaper than extracting globalSerial. + */ + private int knownIndexOf(long tagId) { + long[] ids = this.knownIds; + int n = this.knownCount; + for (int i = 0; i < n; ++i) { + if (ids[i] == tagId) return i; + } + return -1; + } - Entry tagEntry = lastGroup.findInChain(hash, tag); - return tagEntry; + private void ensureKnownCapacity() { + if (this.knownIds == null) { + this.knownIds = new long[KNOWN_INIT_CAP]; + this.knownValues = new Object[KNOWN_INIT_CAP]; + } else if (this.knownCount == this.knownIds.length) { + int newCap = this.knownIds.length << 1; + this.knownIds = Arrays.copyOf(this.knownIds, newCap); + this.knownValues = Arrays.copyOf(this.knownValues, newCap); } + } + + /** + * Presence bit for {@code tagId}: {@code 1L << slot}. Colored slots are < 64; unslotted stored + * tags ({@link KnownTagCodec#NO_SLOT}) fold onto one shared bit via {@code slot & 63} — crude for + * them, but the scan stays authoritative. + */ + private static long knownSlotBit(long tagId) { + return 1L << (KnownTagCodec.slot(tagId) & 63); + } + + /** + * Whether {@code tagId} MAY be present in the dense store (its slot bit is set), vs DEFINITELY + * absent (the bit is clear ⟹ skip the scan). + */ + private boolean knownMaybePresent(long tagId) { + return (this.knownOccupancy & knownSlotBit(tagId)) != 0; + } + + /** + * Stores a known tag's value densely (no {@link Entry} alloc). Overwrites in place when present + * (returning the prior value materialized as an Entry, per the {@code Map} contract — usually + * discarded by {@code set}); otherwise appends, growing x2 as needed. The occupancy presence + * filter skips the {@link #knownIndexOf} scan when the tag is definitely absent (the common + * per-build case), so an append is O(1) instead of O(n). + */ + private Entry putKnownValue(long tagId, Object value) { + long slotBit = knownSlotBit(tagId); + // maybe present only if the slot bit is set; a clear bit ⟹ definitely absent ⟹ append + if ((this.knownOccupancy & slotBit) != 0) { + int i = this.knownIndexOf(tagId); + if (i >= 0) { + Object prior = this.knownValues[i]; + this.knownValues[i] = value; + return materializeKnown(tagId, prior); + } + // filter false positive (slot collision) -> fall through to append + } + this.ensureKnownCapacity(); + int idx = this.knownCount++; + this.knownIds[idx] = tagId; + this.knownValues[idx] = value; + this.knownOccupancy |= slotBit; + this.knownTraceLevel |= KnownTagCodec.isTraceLevel(tagId); return null; } + /** Raw dense value for {@code tagId}, or {@code null} when absent (no Entry, no boxing). */ + private Object knownRawValue(long tagId) { + if (!this.knownMaybePresent(tagId)) return null; // definitely absent, no scan + int i = this.knownIndexOf(tagId); + return i < 0 ? null : this.knownValues[i]; + } + + /** + * Removes a known tag from the dense store (swap-with-last), returning the prior Entry or null. + */ + private Entry removeKnown(long tagId) { + if (!this.knownMaybePresent(tagId)) return null; // definitely absent + int i = this.knownIndexOf(tagId); + 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 intentionally NOT cleared: a stale-set bit only costs a scan; clearing could + // drop a bit still shared (via collision) by a present id -> false negative. + return materializeKnown(tagId, prior); + } + + /** Materializes a transient Entry for a dense (id, value) pair — only on explicit get/iterate. */ + private static Entry materializeKnown(long tagId, Object value) { + return Entry.newAnyEntry(KnownTagCodec.nameOf(tagId), value); + } + + /** + * Whether an ancestor dense entry ({@code tagId}, declared at level {@code fromAncestor}) is + * visible from this leaf under read-through: not shadowed by a nearer level's dense entry of the + * same id and not tombstoned by a nearer level. Chain-aware mirror of {@link #parentEntryVisible} + * for the dense store. (Disjointness: a known tag never buckets, so no bucket shadow check is + * needed.) + */ + private boolean parentDenseVisible(long tagId, TagMap fromAncestor) { + // Trace and span tags reuse the same slots, so a trace-level ancestor entry can only be + // shadowed by a nearer level that ALSO holds trace-level tags. A span map (knownTraceLevel + // false) never shadows a trace tag — skip its occupancy+scan entirely (the level-bit win). A + // span-level ancestor entry keeps the plain occupancy-filtered scan below. + boolean traceLevelTag = KnownTagCodec.isTraceLevel(tagId); + String tag = null; // resolved lazily, only if a nearer level carries tombstones + for (TagMap nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + // shadowed by a nearer dense entry — the occupancy filter prunes the scan when definitely + // absent, so a nearer level with disjoint slots never pays a scan here (read-through win) + if ((!traceLevelTag || nearer.knownTraceLevel) + && nearer.knownMaybePresent(tagId) + && nearer.knownIndexOf(tagId) >= 0) return false; + if (nearer.removedFromParent != null) { + if (tag == null) tag = KnownTagCodec.nameOf(tagId); + if (nearer.removedFromParent.contains(tag)) return false; // tombstoned by a nearer level + } + } + return true; + } + @Deprecated @Override public Object put(@Nonnull String tag, Object value) { - TagMap.Entry entry = this.getAndSet(Entry.newAnyEntry(tag, value)); + TagMap.Entry entry = this.getAndSet(tag, value); return entry == null ? null : entry.objectValue(); } - /** A null reader is a no-op (see the null-tolerance contract on {@link #getAndSet(Entry)}). */ + /** A null reader (or a reader with no entry) is a no-op. */ public void set(@Nullable TagMap.EntryReader newEntryReader) { if (newEntryReader == null) { return; } - this.getAndSet(newEntryReader.entry()); + Entry entry = newEntryReader.entry(); + if (entry != null) { + this.putEntry(entry); + } } + // The set(String, ...) family resolves keyOf FIRST: a known tag stores its value densely with no + // Entry (boxing the primitive only on that branch) and no parent-fallback lookup (set discards + // the prior value); a custom tag takes the typed bucket insert (no boxing for primitives). public void set(@Nonnull String tag, @Nonnull Object value) { - this.getAndSet(Entry.newAnyEntry(tag, value)); + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L; + if (KnownTagCodec.isStored(id)) { + this.putKnownLocal(id, tag, value); + } else { + this.putBucketEntry(Entry.newAnyEntry(tag, value)); + } } public void set(@Nonnull String tag, @Nonnull CharSequence value) { - this.getAndSet(Entry.newObjectEntry(tag, value)); + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L; + if (KnownTagCodec.isStored(id)) { + this.putKnownLocal(id, tag, value); + } else { + this.putBucketEntry(Entry.newObjectEntry(tag, value)); + } } public void set(@Nonnull String tag, boolean value) { - this.getAndSet(Entry.newBooleanEntry(tag, value)); + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L; + if (KnownTagCodec.isStored(id)) { + this.putKnownLocal(id, tag, Boolean.valueOf(value)); + } else { + this.putBucketEntry(Entry.newBooleanEntry(tag, value)); + } } public void set(@Nonnull String tag, int value) { - this.getAndSet(Entry.newIntEntry(tag, value)); + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L; + if (KnownTagCodec.isStored(id)) { + this.putKnownLocal(id, tag, Integer.valueOf(value)); + } else { + this.putBucketEntry(Entry.newIntEntry(tag, value)); + } } public void set(@Nonnull String tag, long value) { - this.getAndSet(Entry.newLongEntry(tag, value)); + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L; + if (KnownTagCodec.isStored(id)) { + this.putKnownLocal(id, tag, Long.valueOf(value)); + } else { + this.putBucketEntry(Entry.newLongEntry(tag, value)); + } } public void set(@Nonnull String tag, float value) { - this.getAndSet(Entry.newFloatEntry(tag, value)); + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L; + if (KnownTagCodec.isStored(id)) { + this.putKnownLocal(id, tag, Float.valueOf(value)); + } else { + this.putBucketEntry(Entry.newFloatEntry(tag, value)); + } } public void set(@Nonnull String tag, double value) { - this.getAndSet(Entry.newDoubleEntry(tag, value)); + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L; + if (KnownTagCodec.isStored(id)) { + this.putKnownLocal(id, tag, Double.valueOf(value)); + } else { + this.putBucketEntry(Entry.newDoubleEntry(tag, value)); + } } /** @@ -1201,10 +1620,84 @@ public Entry getAndSet(@Nullable Entry newEntry) { if (newEntry == null) { return null; } + return this.getAndSetWithFallback(newEntry); + } + + /** + * Local insert (via {@link #putEntry}) plus the read-through parent fallback for the prior + * visible value (Map contract). When no local entry was replaced and the key was not tombstoned, + * the prior visible value is the nearest ancestor's, resolved through {@link #getEntry} (which + * handles both dense known tags and bucketed custom tags). Shared by {@link #getAndSet(Entry)} + * and the {@code getAndSet(String, ...)} overloads. + */ + private Entry getAndSetWithFallback(@Nonnull Entry newEntry) { + // Capture whether the key was tombstoned BEFORE putEntry clears it: a tombstoned key had no + // visible prior value (it was removed), so getAndSet must report null rather than the parent's. + boolean wasTombstoned = + this.removedFromParent != null && this.removedFromParent.contains(newEntry.tag); + + Entry priorLocal = this.putEntry(newEntry); + if (priorLocal != null) { + return priorLocal; // replaced a local entry -> that is the prior value + } + // No local entry was replaced. The prior visible value, if any, was the parent's -- unless the + // key was tombstoned (then it was not visible). set(...) skips this via putEntry (no prior). + if (wasTombstoned || this.parent == null) { + return null; + } + return this.parent.getEntry(newEntry.tag); + } + + /** + * Inserts or replaces a local entry, returning the replaced local Entry (or null if none). Does + * NOT consult the read-through parent -- the {@code set(...)} methods use this so they never pay + * for a prior-value lookup they discard; {@link #getAndSetWithFallback} layers the parent + * fallback on top. Routes a known tag to the dense store, a custom tag to the hash buckets. + */ + private Entry putEntry(@Nonnull Entry newEntry) { + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(newEntry.tag) : 0L; + if (KnownTagCodec.isStored(id)) { + return this.putKnownLocal(id, newEntry.tag, newEntry.objectValue()); + } + return this.putBucketEntry(newEntry); + } + /** + * Stores a known tag's value densely with NO Entry retained (the alloc win) and NO parent + * fallback — the local-only counterpart used by {@code set} and {@link #putEntry}. Returns the + * prior LOCAL dense value materialized as an Entry (Map contract); usually discarded. + */ + private Entry putKnownLocal(long id, String tag, Object value) { this.checkWriteAccess(); + if (this.removedFromParent != null) { + this.removedFromParent.remove(tag); + } + return this.putKnownValue(id, value); + } - Object[] thisBuckets = this.buckets; + /** Copy-on-write the shared empty buckets to a private array on the first bucket write. */ + private Object[] materializeBuckets() { + Object[] b = this.buckets; + if (b == EMPTY_BUCKETS) { + b = new Object[1 << 4]; + this.buckets = b; + } + return b; + } + + /** + * Stores an entry in the hash buckets — the unknown/custom-tag local path (no parent fallback). + */ + private Entry putBucketEntry(@Nonnull Entry newEntry) { + this.checkWriteAccess(); + + // Re-setting a key clears any read-through tombstone for it (the new value overrides the + // removal). Gated on the lazy field, so this is a no-op for the common no-tombstone case. + if (this.removedFromParent != null) { + this.removedFromParent.remove(newEntry.tag); + } + + Object[] thisBuckets = this.materializeBuckets(); int newHash = newEntry.hash(); int bucketIndex = newHash & (thisBuckets.length - 1); @@ -1249,32 +1742,36 @@ public Entry getAndSet(@Nullable Entry newEntry) { return null; } + // Each getAndSet(String, ...) builds the typed Entry (no boxing for primitives) then funnels + // through getAndSetWithFallback, which routes a known tag to the dense store (dropping the Entry) + // and a custom tag to the buckets, layering the read-through parent fallback on top. The Entry- + // free hot path is set(String, ...), which discards the prior value; getAndSet returns it. public Entry getAndSet(@Nonnull String tag, Object value) { - return this.getAndSet(Entry.newAnyEntry(tag, value)); + return this.getAndSetWithFallback(Entry.newAnyEntry(tag, value)); } public Entry getAndSet(@Nonnull String tag, CharSequence value) { - return this.getAndSet(Entry.newObjectEntry(tag, value)); + return this.getAndSetWithFallback(Entry.newObjectEntry(tag, value)); } public TagMap.Entry getAndSet(@Nonnull String tag, boolean value) { - return this.getAndSet(Entry.newBooleanEntry(tag, value)); + return this.getAndSetWithFallback(Entry.newBooleanEntry(tag, value)); } public TagMap.Entry getAndSet(@Nonnull String tag, int value) { - return this.getAndSet(Entry.newIntEntry(tag, value)); + return this.getAndSetWithFallback(Entry.newIntEntry(tag, value)); } public TagMap.Entry getAndSet(@Nonnull String tag, long value) { - return this.getAndSet(Entry.newLongEntry(tag, value)); + return this.getAndSetWithFallback(Entry.newLongEntry(tag, value)); } public TagMap.Entry getAndSet(@Nonnull String tag, float value) { - return this.getAndSet(Entry.newFloatEntry(tag, value)); + return this.getAndSetWithFallback(Entry.newFloatEntry(tag, value)); } public TagMap.Entry getAndSet(@Nonnull String tag, double value) { - return this.getAndSet(Entry.newDoubleEntry(tag, value)); + return this.getAndSetWithFallback(Entry.newDoubleEntry(tag, value)); } public void putAll(Map map) { @@ -1295,10 +1792,11 @@ private void putAllUnoptimizedMap(Map that) } /** - * Similar to {@link Map#putAll(Map)} but optimized to quickly copy from one TagMap to another. + * Similar to {@link Map#putAll(Map)} but optimized to quickly copy from one TagMap to another * - *

    Takes advantage of the consistent TagMap layout to quickly handle each bucket. And similar - * to {@link TagMap#getAndSet(Entry)} this method shares Entry objects from the source TagMap. + *

    For optimized TagMaps, this method takes advantage of the consistent TagMap layout to + * quickly handle each bucket. And similar to {@link TagMap#getAndSet(Entry)} this method shares + * Entry objects from the source TagMap */ public void putAll(TagMap that) { this.checkWriteAccess(); @@ -1307,7 +1805,16 @@ public void putAll(TagMap that) { } private void putAllOptimizedMap(TagMap that) { - if (this.size == 0) { + if (that.parent != null) { + // read-through source: the bucket-copy paths below only see that's local entries, so they + // would drop entries visible only through that's ancestor chain (and ignore its tombstones). + // Union-copy the full visible set instead -- still shares the source Entry objects. + that.forEach(this, (self, entry) -> self.set(entry)); + return; + } + // "empty" must consider BOTH local regions — a map with only dense entries has size == 0 but is + // not empty, and putAllIntoEmptyMap would clobber its dense store. + if (this.size == 0 && this.knownCount == 0) { this.putAllIntoEmptyMap(that); } else { this.putAllMerge(that); @@ -1315,7 +1822,9 @@ private void putAllOptimizedMap(TagMap that) { } private void putAllMerge(TagMap that) { - Object[] thisBuckets = this.buckets; + // COW our buckets only if the source has bucket entries to merge in; otherwise the loop below + // writes nothing and the shared empty buckets stay shared. + Object[] thisBuckets = (that.size > 0) ? this.materializeBuckets() : this.buckets; Object[] thatBuckets = that.buckets; // Since TagMap-s don't support expansion, buckets are perfectly aligned @@ -1426,33 +1935,51 @@ private void putAllMerge(TagMap that) { } } } + + // merge the source's dense known-tag entries; incoming clobbers existing (same as buckets) + for (int i = 0; i < that.knownCount; ++i) { + this.putKnownValue(that.knownIds[i], that.knownValues[i]); + } } /* * Specially optimized version of putAll for the common case of destination map being empty */ private void putAllIntoEmptyMap(TagMap that) { - Object[] thisBuckets = this.buckets; - Object[] thatBuckets = that.buckets; - - // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check - // elimination - for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { - Object thatBucket = thatBuckets[i]; - - // faster to explicitly null check first, then do instanceof - if (thatBucket == null) { - // do nothing - } else if (thatBucket instanceof BucketGroup) { - // if it is a BucketGroup, then need to clone - BucketGroup thatGroup = (BucketGroup) thatBucket; + // Only copy buckets (and COW ours) when the source actually has bucket entries; an all-known + // source leaves us on the shared empty buckets. + if (that.size > 0) { + Object[] thisBuckets = this.materializeBuckets(); + Object[] thatBuckets = that.buckets; + + // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound + // check elimination + for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { + Object thatBucket = thatBuckets[i]; + + // faster to explicitly null check first, then do instanceof + if (thatBucket == null) { + // do nothing + } else if (thatBucket instanceof BucketGroup) { + // if it is a BucketGroup, then need to clone + BucketGroup thatGroup = (BucketGroup) thatBucket; - thisBuckets[i] = thatGroup.cloneChain(); - } else { // if ( thatBucket instanceof Entry ) - thisBuckets[i] = thatBucket; + thisBuckets[i] = thatGroup.cloneChain(); + } else { // if ( thatBucket instanceof Entry ) + thisBuckets[i] = thatBucket; + } } + this.size = that.size; + } + + // clone the dense known-tag store (values are immutable boxes/objects -> safe to share refs) + if (that.knownCount > 0) { + this.knownIds = Arrays.copyOf(that.knownIds, that.knownIds.length); + this.knownValues = Arrays.copyOf(that.knownValues, that.knownValues.length); + this.knownCount = that.knownCount; + this.knownOccupancy = that.knownOccupancy; + this.knownTraceLevel = that.knownTraceLevel; } - this.size = that.size; } public void fillMap(Map map) { @@ -1471,6 +1998,9 @@ public void fillMap(Map map) { thisGroup.fillMapFromChain(map); } } + for (int i = 0; i < this.knownCount; ++i) { + map.put(KnownTagCodec.nameOf(this.knownIds[i]), this.knownValues[i]); + } } public void fillStringMap(Map stringMap) { @@ -1489,6 +2019,11 @@ public void fillStringMap(Map stringMap) { thisGroup.fillStringMapFromChain(stringMap); } } + for (int i = 0; i < this.knownCount; ++i) { + stringMap.put( + KnownTagCodec.nameOf(this.knownIds[i]), + TagValueConversions.toString(this.knownValues[i])); + } } @Override @@ -1506,6 +2041,37 @@ public boolean remove(String tag) { public Entry getAndRemove(String tag) { this.checkWriteAccess(); + Entry localRemoved = this.removeLocal(tag); + + TagMap parent = this.parent; + if (parent != null) { + // Read-through: if the parent still exposes this key, removing it must also hide it from + // fall-through — install a tombstone. The prior *visible* value (Map.remove contract) is the + // local entry if there was one, otherwise the parent's (which we now hide). Single-parent in + // phase 1; rare path (only when removing a parent-exposed key). + boolean alreadyTombstoned = + this.removedFromParent != null && this.removedFromParent.contains(tag); + if (!alreadyTombstoned) { + Entry parentEntry = parent.getEntry(tag); + if (parentEntry != null) { + if (this.removedFromParent == null) { + this.removedFromParent = new HashSet<>(); + } + this.removedFromParent.add(tag); + return localRemoved != null ? localRemoved : parentEntry; + } + } + } + return localRemoved; + } + + /** Removes an entry from this map's own storage only — no parent/tombstone handling. */ + private Entry removeLocal(String tag) { + long id = KnownTagCodec.DENSE_STORE ? KnownTagCodec.keyOf(tag) : 0L; + if (KnownTagCodec.isStored(id)) { + return this.removeKnown(id); + } + Object[] thisBuckets = this.buckets; int hash = TagMap.Entry._hash(tag); @@ -1543,8 +2109,14 @@ public Entry getAndRemove(String tag) { } public TagMap copy() { - TagMap copy = new TagMap(); + // Construct with the same (frozen, shared) parent up front — the parent is fixed at + // construction. putAll then clones this map's own (local) buckets + size. The copy stays + // independently mutable (writes land on its local buckets, never the shared parent). + TagMap copy = new TagMap(this.parent); copy.putAllIntoEmptyMap(this); + if (this.removedFromParent != null) { + copy.removedFromParent = new HashSet<>(this.removedFromParent); + } return copy; } @@ -1567,6 +2139,15 @@ public Stream stream() { @Override public void forEach(Consumer consumer) { + // local dense known tags via a reused flyweight (no per-entry Entry alloc — the serialize win) + if (this.knownCount > 0) { + EntryReadingHelper reader = new EntryReadingHelper(); + for (int i = 0; i < this.knownCount; ++i) { + reader.set(KnownTagCodec.nameOf(this.knownIds[i]), this.knownValues[i]); + consumer.accept(reader); + } + } + Object[] thisBuckets = this.buckets; for (int i = 0; i < thisBuckets.length; ++i) { @@ -1582,9 +2163,63 @@ public void forEach(Consumer consumer) { thisGroup.forEachInChain(consumer); } } + + // read-through: parent entries not shadowed locally or tombstoned. Kept out of line so the + // common parent == null path stays byte-identical to before (small / inlinable). + if (this.parent != null) { + this.forEachParent(consumer); + } + } + + private void forEachParent(Consumer consumer) { + // Walk the ancestor chain, nearest first. Each entry is emitted once, by the nearest level that + // defines its key, when not shadowed by a nearer level and not tombstoned. Dense known tags are + // emitted via a reused flyweight (no per-entry Entry alloc — the serialize win). + EntryReadingHelper reader = null; + for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + long[] ancestorIds = ancestor.knownIds; + int ancestorKnownCount = ancestor.knownCount; + if (ancestorKnownCount > 0) { + Object[] ancestorValues = ancestor.knownValues; + if (reader == null) reader = new EntryReadingHelper(); + for (int i = 0; i < ancestorKnownCount; ++i) { + long id = ancestorIds[i]; + if (this.parentDenseVisible(id, ancestor)) { + reader.set(KnownTagCodec.nameOf(id), ancestorValues[i]); + consumer.accept(reader); + } + } + } + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + if (parentBucket instanceof Entry) { + Entry parentEntry = (Entry) parentBucket; + if (parentEntryVisible(parentEntry, ancestor)) consumer.accept(parentEntry); + } else if (parentBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) parentBucket; + curGroup != null; + curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry parentEntry = curGroup._entryAt(j); + if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) + consumer.accept(parentEntry); + } + } + } + } + } } public void forEach(T thisObj, BiConsumer consumer) { + if (this.knownCount > 0) { + EntryReadingHelper reader = new EntryReadingHelper(); + for (int i = 0; i < this.knownCount; ++i) { + reader.set(KnownTagCodec.nameOf(this.knownIds[i]), this.knownValues[i]); + consumer.accept(thisObj, reader); + } + } + Object[] thisBuckets = this.buckets; for (int i = 0; i < thisBuckets.length; ++i) { @@ -1600,10 +2235,61 @@ public void forEach(T thisObj, BiConsumer con thisGroup.forEachInChain(thisObj, consumer); } } + + // read-through: parent entries not shadowed locally or tombstoned (kept out of line). + if (this.parent != null) { + this.forEachParent(thisObj, consumer); + } + } + + private void forEachParent(T thisObj, BiConsumer consumer) { + EntryReadingHelper reader = null; + for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + int ancestorKnownCount = ancestor.knownCount; + if (ancestorKnownCount > 0) { + long[] ancestorIds = ancestor.knownIds; + Object[] ancestorValues = ancestor.knownValues; + if (reader == null) reader = new EntryReadingHelper(); + for (int i = 0; i < ancestorKnownCount; ++i) { + long id = ancestorIds[i]; + if (this.parentDenseVisible(id, ancestor)) { + reader.set(KnownTagCodec.nameOf(id), ancestorValues[i]); + consumer.accept(thisObj, reader); + } + } + } + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + if (parentBucket instanceof Entry) { + Entry parentEntry = (Entry) parentBucket; + if (parentEntryVisible(parentEntry, ancestor)) consumer.accept(thisObj, parentEntry); + } else if (parentBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) parentBucket; + curGroup != null; + curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry parentEntry = curGroup._entryAt(j); + if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) { + consumer.accept(thisObj, parentEntry); + } + } + } + } + } + } } public void forEach( T thisObj, U otherObj, TriConsumer consumer) { + if (this.knownCount > 0) { + EntryReadingHelper reader = new EntryReadingHelper(); + for (int i = 0; i < this.knownCount; ++i) { + reader.set(KnownTagCodec.nameOf(this.knownIds[i]), this.knownValues[i]); + consumer.accept(thisObj, otherObj, reader); + } + } + Object[] thisBuckets = this.buckets; for (int i = 0; i < thisBuckets.length; ++i) { @@ -1619,13 +2305,69 @@ public void forEach( thisGroup.forEachInChain(thisObj, otherObj, consumer); } } + + // read-through: parent entries not shadowed locally or tombstoned (kept out of line). + if (this.parent != null) { + this.forEachParent(thisObj, otherObj, consumer); + } + } + + private void forEachParent( + T thisObj, U otherObj, TriConsumer consumer) { + EntryReadingHelper reader = null; + for (TagMap ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + int ancestorKnownCount = ancestor.knownCount; + if (ancestorKnownCount > 0) { + long[] ancestorIds = ancestor.knownIds; + Object[] ancestorValues = ancestor.knownValues; + if (reader == null) reader = new EntryReadingHelper(); + for (int i = 0; i < ancestorKnownCount; ++i) { + long id = ancestorIds[i]; + if (this.parentDenseVisible(id, ancestor)) { + reader.set(KnownTagCodec.nameOf(id), ancestorValues[i]); + consumer.accept(thisObj, otherObj, reader); + } + } + } + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object parentBucket = parentBuckets[i]; + if (parentBucket instanceof Entry) { + Entry parentEntry = (Entry) parentBucket; + if (parentEntryVisible(parentEntry, ancestor)) + consumer.accept(thisObj, otherObj, parentEntry); + } else if (parentBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) parentBucket; + curGroup != null; + curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry parentEntry = curGroup._entryAt(j); + if (parentEntry != null && parentEntryVisible(parentEntry, ancestor)) { + consumer.accept(thisObj, otherObj, parentEntry); + } + } + } + } + } + } } public void clear() { this.checkWriteAccess(); - Arrays.fill(this.buckets, null); + // Drop the private bucket array back to the shared empty sentinel (also avoids mutating it). + this.buckets = EMPTY_BUCKETS; this.size = 0; + // clear() removes ALL mappings, including any inherited through read-through. Detaching the + // parent (rather than tombstoning every inherited key) is simpler and cheaper, and leaves an + // empty, parent-less map. Detach is one-way -- the parent is never re-pointed. + this.parent = null; + this.removedFromParent = null; + this.knownIds = null; + this.knownValues = null; + this.knownCount = 0; + this.knownOccupancy = 0L; + this.knownTraceLevel = false; } public TagMap freeze() { @@ -1680,10 +2422,26 @@ void checkIntegrity() { } } + // dense store: ids must be unique (no tag stored twice) and the count within array 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) { + for (int j = i + 1; j < this.knownCount; ++j) { + if (this.knownIds[i] == this.knownIds[j]) { + throw new IllegalStateException("duplicate known id"); + } + } + } + } + if (this.size != this.computeSize()) { throw new IllegalStateException("incorrect size"); } - if (this.isEmpty() != this.checkIfEmpty()) { + // Local-structure invariant: the size counter's emptiness must match the local buckets. Uses + // the local (this.size == 0), NOT isEmpty(), which under read-through resolves the parent too. + if ((this.size == 0) != this.checkIfEmpty()) { throw new IllegalStateException("incorrect empty status"); } } @@ -1801,33 +2559,46 @@ String toInternalString() { } abstract static class IteratorBase { - private final Object[] buckets; + private final TagMap map; + + // the level whose buckets are currently being walked: the leaf (map) first, then each ancestor + // in turn (read-through union). map == level means we're on the leaf's own entries. + private TagMap level; + private Object[] buckets; - private Entry nextEntry; + // Currency is EntryReader, not Entry: a BUCKET entry is its own (real, retain-safe) Entry, but + // a + // DENSE entry is emitted via the reused denseReader flyweight (alloc-free, "use now"). This is + // the contract of TagMap.iterator()/keySet()/values(). entrySet() (Iterator) sits on + // top and calls .entry() per next() to get a real retain-safe Entry (see EntriesIterator). + private EntryReader nextEntry; + private EntryReadingHelper denseReader; // lazily created on the first dense emit private int bucketIndex = -1; private BucketGroup group = null; private int groupIndex = 0; + // dense-store cursor for the current level's known tags; advance() resets it when it moves to + // the next ancestor level (read-through union). + private int knownIndex = 0; + IteratorBase(TagMap map) { + this.map = map; + this.level = map; this.buckets = map.buckets; } public final boolean hasNext() { if (this.nextEntry != null) return true; - while (this.bucketIndex < this.buckets.length) { - this.nextEntry = this.advance(); - if (this.nextEntry != null) return true; - } - - return false; + this.nextEntry = this.advance(); + return this.nextEntry != null; } - final Entry nextEntryOrThrowNoSuchElement() { + final EntryReader nextEntryOrThrowNoSuchElement() { if (this.nextEntry != null) { - Entry nextEntry = this.nextEntry; + EntryReader nextEntry = this.nextEntry; this.nextEntry = null; return nextEntry; } @@ -1839,9 +2610,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; } @@ -1849,7 +2620,60 @@ final Entry nextEntryOrNull() { return this.hasNext() ? this.nextEntry : null; } - private final Entry advance() { + private final EntryReader advance() { + while (true) { + // phase 1: drain the current level's dense known tags before its buckets. Leaf dense always + // emits; ancestor dense only if visible from the leaf (not shadowed by a nearer dense entry + // and not tombstoned). Emitted via the reused denseReader flyweight -- NO per-entry Entry + // alloc (the read/serialize alloc win). + if (this.knownIndex < this.level.knownCount) { + int i = this.knownIndex++; + long id = this.level.knownIds[i]; + if (this.level == this.map || this.map.parentDenseVisible(id, this.level)) { + return this.emitDense(id, this.level.knownValues[i]); + } + continue; // ancestor dense entry shadowed/tombstoned -> skip + } + + // phase 2: the current level's buckets. + Entry tagEntry = this.rawAdvance(); + if (tagEntry != null) { + // leaf entries emit as-is; ancestor entries only if visible from the leaf -- not shadowed + // by a nearer level and not tombstoned. (parentEntryVisible walks the nearer levels.) + if (this.level == this.map || this.map.parentEntryVisible(tagEntry, this.level)) { + return tagEntry; + } + continue; // ancestor entry shadowed/tombstoned -> skip + } + + // current level exhausted; advance to the next ancestor (read-through union), resetting + // both + // the per-level dense cursor and the bucket cursor for the new level. + if (this.level.parent != null) { + this.level = this.level.parent; + this.knownIndex = 0; + this.buckets = this.level.buckets; + this.bucketIndex = -1; + this.group = null; + this.groupIndex = 0; + continue; + } + return null; + } + } + + /** Sets and returns the reused dense flyweight (lazily created); "use now", do not retain. */ + private EntryReader emitDense(long tagId, Object value) { + EntryReadingHelper reader = this.denseReader; + if (reader == null) { + reader = this.denseReader = new EntryReadingHelper(); + } + reader.set(KnownTagCodec.nameOf(tagId), value); + return reader; + } + + /** Next raw entry in the current bucket array, ignoring shadowing/tombstones. */ + private final Entry rawAdvance() { while (this.bucketIndex < this.buckets.length) { if (this.group != null) { for (++this.groupIndex; this.groupIndex < BucketGroup.LEN; ++this.groupIndex) { @@ -2367,19 +3191,36 @@ static final class Entries extends AbstractSet> { @Override public int size() { - return this.map.computeSize(); + return this.map.size(); } @Override public boolean isEmpty() { - return this.map.checkIfEmpty(); + return this.map.isEmpty(); } @Override public Iterator> iterator() { - @SuppressWarnings({"rawtypes", "unchecked"}) - Iterator> iter = (Iterator) this.map.iterator(); - return iter; + return new EntriesIterator(this.map); + } + } + + /** + * entrySet() yields real, retain-safe {@code Map.Entry} objects. It sits on top of the + * EntryReader iterator and materializes each via {@code .entry()}: a bucket entry's reader IS the + * real stored Entry (returns {@code this}, free); a dense entry's flyweight materializes a fresh + * Entry. Deliberately NOT alloc-optimized for dense — bulk reads use {@code forEach}/EntryReader, + * and manual instrumentation does point get/set, not bulk entrySet iteration. + */ + static final class EntriesIterator extends IteratorBase + implements Iterator> { + EntriesIterator(TagMap map) { + super(map); + } + + @Override + public Map.Entry next() { + return this.nextEntryOrThrowNoSuchElement().entry(); } } @@ -2392,12 +3233,12 @@ static final class Keys extends AbstractSet { @Override public int size() { - return this.map.computeSize(); + return this.map.size(); } @Override public boolean isEmpty() { - return this.map.checkIfEmpty(); + return this.map.isEmpty(); } @Override @@ -2431,12 +3272,12 @@ static final class Values extends AbstractCollection { @Override public int size() { - return this.map.computeSize(); + return this.map.size(); } @Override public boolean isEmpty() { - return this.map.checkIfEmpty(); + return this.map.isEmpty(); } @Override diff --git a/internal-api/src/main/java/datadog/trace/util/StringIndex.java b/internal-api/src/main/java/datadog/trace/util/StringIndex.java new file mode 100644 index 00000000000..d7709225b0e --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/StringIndex.java @@ -0,0 +1,324 @@ +package datadog.trace.util; + +import java.lang.reflect.Array; +import java.util.function.Function; +import java.util.function.ToIntFunction; +import java.util.function.ToLongFunction; + +/** + * Flat open-addressed name set. Generic — it knows only names. + * + *

    Two ways to use it, trading convenience for indirection: + * + *

      + *
    • {@link EmbeddingSupport} — static algorithm over raw arrays you embed in your + * own (ideally {@code static final}) fields; the JIT folds the refs to constants. The fastest + * path, nothing to dereference. (Named for the same role as {@code + * LightMap.EmbeddingSupport}: the static ops you reach for when you own the backing arrays + * directly.) + *
    • The {@code StringIndex} instance ({@link #of}) — a convenience wrapper holding the + * arrays; {@link #indexOf}/{@link #contains} delegate to {@link EmbeddingSupport}. Costs an + * instance-field load per call (the indirection the static path removes) — fine off the hot + * path. + *
    + * + *

    Consumers attach their own parallel payload arrays (ids, values, ...) sized to {@link + * #numSlots()} and indexed by the slot {@code indexOf} returns. {@code mapValues}/{@code + * mapIntValues}/{@code mapLongValues} build such an array at construction; {@code lookup}/{@code + * lookupOrDefault} read one back in a single call (slot resolve + array read). + * + *

    Slot 0-value is the empty sentinel: {@link EmbeddingSupport#hash} never returns 0, so {@code + * hashes[i] == 0} unambiguously means an empty slot. + * + *

    Trades memory for simplicity (and, incidentally, speed). The table is 2x-oversized ({@link + * EmbeddingSupport#DEFAULT_LOAD_FACTOR} ≤ 0.5) so build-time placement always finds a free slot + * and never has to rehash or resize — short probe chains are a welcome side effect, not the design + * goal. The cached {@code int[]} hashes gate {@code equals()}. Both cost memory, so a + * tightly-packed set is more compact: prefer {@link java.util.Set#copyOf} (the JDK's {@code SetN}) + * when you only need membership, and reach for {@code StringIndex} for the {@code + * indexOf}->parallel-array (name→id) capability or the hot, allocation-free static {@link + * EmbeddingSupport} path. (If footprint matters more than build simplicity, build via {@link + * EmbeddingSupport#capacityFor(int, float)} at a higher load factor — placement still finds a slot + * at any factor < 1, so no rehash is needed.) + */ +public final class StringIndex { + private final int[] hashes; + private final String[] names; + + private StringIndex(int[] hashes, String[] names) { + this.hashes = hashes; + this.names = names; + } + + /** + * Convenience instance — wraps the placed arrays. For the hot path prefer raw {@link + * EmbeddingSupport}. + */ + public static StringIndex of(String... names) { + Data data = EmbeddingSupport.create(names); + return new StringIndex(data.hashes, data.names); + } + + /** + * Slot of {@code name}, or -1. Delegates to {@link EmbeddingSupport} on the instance's arrays. + */ + public int indexOf(String name) { + return EmbeddingSupport.indexOf(this.hashes, this.names, name); + } + + public boolean contains(String name) { + return indexOf(name) >= 0; + } + + /** Table size — allocate parallel payload arrays of this length. */ + public int numSlots() { + return hashes.length; + } + + // --- value mapping: build a slot-aligned parallel array (off the hot path) --- + + /** + * Builds a slot-aligned {@code T[]} of values: {@code out[indexOf(name)] == fn.apply(name)} for + * every indexed name; other slots stay {@code null}. {@code type} is the array element type (Java + * can't allocate a generic array without it). Pair with {@link #lookup(Object[], String)}. + */ + public T[] mapValues(Class type, Function fn) { + return EmbeddingSupport.mapValues(this.names, type, fn); + } + + /** Slot-aligned {@code int[]} of values; absent slots stay 0. See {@link #mapValues}. */ + public int[] mapIntValues(ToIntFunction fn) { + return EmbeddingSupport.mapIntValues(this.names, fn); + } + + /** Slot-aligned {@code long[]} of values; absent slots stay 0. See {@link #mapValues}. */ + public long[] mapLongValues(ToLongFunction fn) { + return EmbeddingSupport.mapLongValues(this.names, fn); + } + + // --- lookup: resolve a key and read its parallel value in one call --- + + /** {@code data[indexOf(key)]}, or {@code null} when {@code key} is absent. */ + public T lookup(T[] data, String key) { + return EmbeddingSupport.lookup(this.hashes, this.names, data, key); + } + + /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ + public T lookupOrDefault(T[] data, String key, T defaultValue) { + return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); + } + + /** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */ + public int lookup(int[] data, String key) { + return EmbeddingSupport.lookup(this.hashes, this.names, data, key); + } + + /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ + public int lookupOrDefault(int[] data, String key, int defaultValue) { + return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); + } + + /** {@code data[indexOf(key)]}, or 0 when {@code key} is absent. */ + public long lookup(long[] data, String key) { + return EmbeddingSupport.lookup(this.hashes, this.names, data, key); + } + + /** {@code data[indexOf(key)]}, or {@code defaultValue} when {@code key} is absent. */ + public long lookupOrDefault(long[] data, String key, long defaultValue) { + return EmbeddingSupport.lookupOrDefault(this.hashes, this.names, data, key, defaultValue); + } + + /** Build-time carrier. Pull the fields into your own (static final) fields; don't keep this. */ + public static final class Data { + public final int[] hashes; + public final String[] names; + + Data(int[] hashes, String[] names) { + this.hashes = hashes; + this.names = names; + } + } + + /** + * Static algorithm over raw arrays. Query helpers take raw arrays, never a Data or a StringIndex. + */ + public static final class EmbeddingSupport { + private EmbeddingSupport() {} + + /** Spread of String.hashCode; 0 reserved as the empty sentinel. */ + public static int hash(String name) { + int h = name.hashCode(); // cached on String -> field load + return h == 0 ? 0xDD06 : h ^ (h >>> 16); + } + + /** + * Balanced default load factor — target fill {@code <= 0.5} ({@code >= 2x} capacity). (Mirrors + * {@code FlatHashtable.DEFAULT_LOAD_FACTOR}; duplicated while the two are separate PRs, to be + * unified when the flat-collection family converges.) + */ + public static final float DEFAULT_LOAD_FACTOR = 0.5f; + + /** Sparse load factor — target fill {@code <= 0.25} ({@code >= 4x} capacity). */ + public static final float LOW_LOAD_FACTOR = 0.25f; + + /** Power-of-two capacity for {@code n} names at the {@link #DEFAULT_LOAD_FACTOR}. */ + public static int capacityFor(int n) { + return capacityFor(n, DEFAULT_LOAD_FACTOR); + } + + /** + * Power-of-two capacity for {@code n} names at {@code loadFactor}: the smallest power of two + * {@code >= ceil(n / loadFactor)} (so the achieved fill is {@code <= loadFactor}). {@code n == + * 0} yields a minimal 2-slot table (StringIndex allows the empty set, unlike FlatHashtable). + */ + public static int capacityFor(int n, float loadFactor) { + if (n < 0) { + throw new IllegalArgumentException("n must be non-negative: " + n); + } + if (!(loadFactor > 0f && loadFactor < 1f)) { + throw new IllegalArgumentException("loadFactor must be in (0, 1): " + loadFactor); + } + if (n == 0) { + return 2; // empty set -> minimal table (one always-empty slot suffices, 2 keeps it pow2) + } + int min = (int) Math.ceil(n / (double) loadFactor); + return Integer.highestOneBit(min - 1) << 1; + } + + /** Build the placed table. Returns a Data carrier; pull its arrays into your own fields. */ + public static Data create(String... names) { + int size = capacityFor(names.length); + int[] hashes = new int[size]; + String[] placed = new String[size]; + for (String name : names) { + put(hashes, placed, name, hash(name)); + } + return new Data(hashes, placed); + } + + /** + * Slot-aligned {@code T[]} over placed {@code names}: {@code out[slot] = fn(name)} per name, + * {@code null} elsewhere. {@code type} is the array element type (generic-array allocation). + */ + @SuppressWarnings("unchecked") + public static T[] mapValues(String[] names, Class type, Function fn) { + T[] out = (T[]) Array.newInstance(type, names.length); + for (int slot = 0; slot < names.length; slot++) { + String name = names[slot]; + if (name != null) { + out[slot] = fn.apply(name); + } + } + return out; + } + + /** + * Slot-aligned {@code int[]} over placed {@code names}; {@code out[slot] = fn(name)}, 0 else. + */ + public static int[] mapIntValues(String[] names, ToIntFunction fn) { + int[] out = new int[names.length]; + for (int slot = 0; slot < names.length; slot++) { + String name = names[slot]; + if (name != null) { + out[slot] = fn.applyAsInt(name); + } + } + return out; + } + + /** + * Slot-aligned {@code long[]} over placed {@code names}; {@code out[slot] = fn(name)}, 0 else. + */ + public static long[] mapLongValues(String[] names, ToLongFunction fn) { + long[] out = new long[names.length]; + for (int slot = 0; slot < names.length; slot++) { + String name = names[slot]; + if (name != null) { + out[slot] = fn.applyAsLong(name); + } + } + return out; + } + + /** Build-time placement. Returns the slot. */ + public static int put(int[] hashes, String[] names, String name, int h) { + final int mask = hashes.length - 1; + int i = h & mask; + for (int probes = 0; probes <= mask; probes++, i = (i + 1) & mask) { + if (hashes[i] == 0) { + hashes[i] = h; + names[i] = name; + return i; + } + if (hashes[i] == h && names[i].equals(name)) { + return i; // already present + } + } + throw new IllegalStateException("table full"); // impossible at LF <= 0.5 + } + + /** Probe; returns the slot or -1. Raw arrays — no Data, no instance. */ + public static int indexOf(int[] hashes, String[] names, String name, int h) { + final int mask = hashes.length - 1; + int i = h & mask; + for (int probes = 0; probes <= mask; probes++, i = (i + 1) & mask) { + int sh = hashes[i]; + if (sh == 0) { + return -1; + } + if (sh == h && names[i].equals(name)) { + return i; + } + } + return -1; + } + + public static int indexOf(int[] hashes, String[] names, String name) { + return indexOf(hashes, names, name, hash(name)); + } + + /** Number of slots — the length to size parallel payload arrays to. */ + public static int numSlots(int[] hashes) { + return hashes.length; + } + + /** {@code data[indexOf(...)]}, or {@code null} when {@code key} is absent. */ + public static T lookup(int[] hashes, String[] names, T[] data, String key) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : null; + } + + /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ + public static T lookupOrDefault( + int[] hashes, String[] names, T[] data, String key, T defaultValue) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : defaultValue; + } + + /** {@code data[indexOf(...)]}, or 0 when {@code key} is absent. */ + public static int lookup(int[] hashes, String[] names, int[] data, String key) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : 0; + } + + /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ + public static int lookupOrDefault( + int[] hashes, String[] names, int[] data, String key, int defaultValue) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : defaultValue; + } + + /** {@code data[indexOf(...)]}, or 0 when {@code key} is absent. */ + public static long lookup(int[] hashes, String[] names, long[] data, String key) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : 0L; + } + + /** {@code data[indexOf(...)]}, or {@code defaultValue} when {@code key} is absent. */ + public static long lookupOrDefault( + int[] hashes, String[] names, long[] data, String key, long defaultValue) { + int slot = indexOf(hashes, names, key); + return slot >= 0 ? data[slot] : defaultValue; + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java new file mode 100644 index 00000000000..3e0dccaae56 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/KnownTagsTest.java @@ -0,0 +1,215 @@ +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.assertTrue; + +import datadog.trace.bootstrap.instrumentation.api.Tags; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +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.MethodSource; + +/** + * Parity test for the keyOf substrate: the generated {@link KnownTags} registry + the {@link + * KnownTagCodec.Resolver} it registers. Verifies name ↔ id resolution and the intercepted / + * reserved / stored partitioning. {@code keyOf}/{@code nameOf} depend only on globalSerial + name, + * not on the (dormant) positional layout, so this is independent of the colored slot the tag + * registry assigns. Also covers the slot/level-bit encoding the coloring adds to the id. + */ +class KnownTagsTest { + + /** (name, id) pairs across the groups — keyOf returns the id verbatim (incl. INTERCEPTED). */ + static Stream knownTags() { + return Stream.of( + Arguments.of(Tags.ERROR, KnownTags.ERROR_ID), + Arguments.of(DDTags.PARENT_ID, KnownTags.DD_PARENT_ID), + Arguments.of(DDTags.BASE_SERVICE, KnownTags.DD_BASE_SERVICE_ID), + Arguments.of(Tags.VERSION, KnownTags.VERSION_ID), + Arguments.of("env", KnownTags.ENV_ID), + Arguments.of(DDTags.DJM_ENABLED, KnownTags.DD_DJM_ENABLED_ID), + Arguments.of(DDTags.DSM_ENABLED, KnownTags.DD_DSM_ENABLED_ID), + Arguments.of(DDTags.TRACER_HOST, KnownTags.DD_TRACER_HOST_ID), + Arguments.of(DDTags.DD_INTEGRATION, KnownTags.DD_INTEGRATION_ID), + Arguments.of(DDTags.DD_SVC_SRC, KnownTags.DD_SVC_SRC_ID), + Arguments.of(Tags.PEER_SERVICE, KnownTags.PEER_SERVICE_ID), + Arguments.of(DDTags.PEER_SERVICE_REMAPPED_FROM, KnownTags.DD_PEER_SERVICE_REMAPPED_FROM_ID), + Arguments.of(Tags.HTTP_METHOD, KnownTags.HTTP_METHOD_ID), + Arguments.of(Tags.HTTP_ROUTE, KnownTags.HTTP_ROUTE_ID), + Arguments.of(Tags.HTTP_URL, KnownTags.HTTP_URL_ID), + Arguments.of(Tags.PEER_HOSTNAME, KnownTags.PEER_HOSTNAME_ID), + Arguments.of(Tags.PEER_HOST_IPV4, KnownTags.PEER_IPV4_ID), + Arguments.of(Tags.PEER_HOST_IPV6, KnownTags.PEER_IPV6_ID), + Arguments.of(Tags.PEER_PORT, KnownTags.PEER_PORT_ID), + Arguments.of(Tags.COMPONENT, KnownTags.COMPONENT_ID), + Arguments.of(Tags.SPAN_KIND, KnownTags.SPAN_KIND_ID), + Arguments.of(DDTags.LANGUAGE_TAG_KEY, KnownTags.LANGUAGE_ID), + Arguments.of(Tags.DB_TYPE, KnownTags.DB_TYPE_ID), + Arguments.of(Tags.DB_INSTANCE, KnownTags.DB_INSTANCE_ID), + Arguments.of(Tags.DB_USER, KnownTags.DB_USER_ID), + Arguments.of(Tags.DB_OPERATION, KnownTags.DB_OPERATION_ID), + Arguments.of(Tags.DB_POOL_NAME, KnownTags.DB_POOL_NAME_ID)); + } + + /** + * The subset flagged INTERCEPTED (sign bit) — must agree with the interceptor's needsIntercept. + */ + static Stream interceptedTags() { + return Stream.of( + Arguments.of(KnownTags.ERROR_ID), + Arguments.of(KnownTags.PEER_SERVICE_ID), + Arguments.of(KnownTags.HTTP_METHOD_ID), + Arguments.of(KnownTags.HTTP_URL_ID), + Arguments.of(KnownTags.SPAN_KIND_ID)); + } + + /** + * Trace-level tags (live on the TraceSegment's TagMap) — their id carries the LEVEL_TRACE bit. + */ + static Stream traceLevelTags() { + return Stream.of( + Arguments.of(KnownTags.DD_BASE_SERVICE_ID), + Arguments.of(KnownTags.VERSION_ID), + Arguments.of(KnownTags.ENV_ID), + Arguments.of(KnownTags.LANGUAGE_ID), + Arguments.of(KnownTags.RUNTIME_ID), + Arguments.of(KnownTags.DD_TRACER_HOST_ID), + Arguments.of(KnownTags.DD_DJM_ENABLED_ID)); + } + + /** Span-level tags — their id leaves the LEVEL_TRACE bit clear. */ + static Stream spanLevelTags() { + return Stream.of( + Arguments.of(KnownTags.HTTP_METHOD_ID), + Arguments.of(KnownTags.HTTP_URL_ID), + Arguments.of(KnownTags.DB_TYPE_ID), + Arguments.of(KnownTags.COMPONENT_ID), + Arguments.of(KnownTags.SPAN_KIND_ID), + Arguments.of(KnownTags.PEER_SERVICE_ID)); + } + + @BeforeAll + static void registerResolver() { + // Generated ids are compile-time constants (literal), so a constant reference is inlined and + // never triggers KnownTags.. init() forces class-load -> KnownTagCodec.register. + KnownTags.init(); + } + + @Test + void resolverIsActiveAfterInit() { + assertTrue(KnownTagCodec.isActive()); + assertEquals(KnownTags.SLOT_COUNT, KnownTagCodec.slotCount()); + } + + @ParameterizedTest + @MethodSource("knownTags") + void keyOfResolvesNameToId(String name, long id) { + assertEquals(id, KnownTagCodec.keyOf(name), "keyOf(" + name + ")"); + } + + @ParameterizedTest + @MethodSource("knownTags") + void nameOfResolvesIdToName(String name, long id) { + assertEquals(name, KnownTagCodec.nameOf(id), "nameOf(" + name + ")"); + } + + @ParameterizedTest + @MethodSource("interceptedTags") + void interceptedTagsCarryFlag(long id) { + assertTrue(KnownTagCodec.isIntercepted(id), "isIntercepted"); + } + + @Test + void nonInterceptedTagsDoNotCarryFlag() { + Set intercepted = new HashSet<>(); + interceptedTags().forEach(a -> intercepted.add((Long) a.get()[0])); + knownTags() + .forEach( + a -> { + long id = (Long) a.get()[1]; + if (!intercepted.contains(id)) { + assertFalse(KnownTagCodec.isIntercepted(id), "not intercepted: " + a.get()[0]); + } + }); + } + + @Test + void unknownNamesResolveToZero() { + assertEquals(0L, KnownTagCodec.keyOf("definitely.not.a.known.tag")); + assertEquals(0L, KnownTagCodec.keyOf("http.statuscode")); // close-but-not-listed + assertEquals(0L, KnownTagCodec.keyOf("")); + } + + @Test + void unknownIdsResolveToNullName() { + assertNull(KnownTagCodec.nameOf(0L)); + assertNull(KnownTagCodec.nameOf(KnownTagCodec.makeTagId(9999))); // serial with no assigned tag + } + + @Test + void errorIsReservedTheRestAreStored() { + assertTrue(KnownTagCodec.isReserved(KnownTags.ERROR_ID), "ERROR reserved"); + assertFalse(KnownTagCodec.isStored(KnownTags.ERROR_ID), "ERROR not stored"); + knownTags() + .forEach( + a -> { + long id = (Long) a.get()[1]; + if (id != KnownTags.ERROR_ID) { + assertTrue(KnownTagCodec.isStored(id), "stored: " + a.get()[0]); + assertFalse(KnownTagCodec.isReserved(id), "not reserved: " + a.get()[0]); + } + }); + } + + @Test + void globalSerialsAreUnique() { + List serials = new ArrayList<>(); + knownTags().forEach(a -> serials.add((long) KnownTagCodec.serialNum((Long) a.get()[1]))); + assertEquals(serials.size(), new HashSet<>(serials).size(), "globalSerials must be unique"); + } + + @ParameterizedTest + @MethodSource("traceLevelTags") + void traceLevelTagsCarryLevelBit(long id) { + assertTrue(KnownTagCodec.isTraceLevel(id), "isTraceLevel"); + } + + @ParameterizedTest + @MethodSource("spanLevelTags") + void spanLevelTagsClearLevelBit(long id) { + assertFalse(KnownTagCodec.isTraceLevel(id), "not trace-level"); + } + + @Test + void levelBitCompositionRoundTrips() { + long spanId = KnownTagCodec.makeTagId(300, 5); // no level bit + assertFalse(KnownTagCodec.isTraceLevel(spanId)); + long traceId = KnownTagCodec.traceLevel(spanId); + assertTrue(KnownTagCodec.isTraceLevel(traceId)); + // level bit is orthogonal to serial/slot — both survive setting it + assertEquals(KnownTagCodec.serialNum(spanId), KnownTagCodec.serialNum(traceId)); + assertEquals(KnownTagCodec.slot(spanId), KnownTagCodec.slot(traceId)); + assertEquals(traceId, KnownTagCodec.traceLevel(traceId), "traceLevel is idempotent"); + } + + @Test + void slotEncodingRoundTrips() { + long id = KnownTagCodec.makeTagId(FIRST_STORED_SERIAL_PLUS_7, 7); + assertEquals(FIRST_STORED_SERIAL_PLUS_7, KnownTagCodec.serialNum(id)); + assertEquals(7, KnownTagCodec.slot(id)); + assertFalse(KnownTagCodec.isUnslotted(id)); + + long unslotted = KnownTagCodec.makeTagId(FIRST_STORED_SERIAL_PLUS_7); // NO_SLOT + assertEquals(KnownTagCodec.NO_SLOT, KnownTagCodec.slot(unslotted)); + assertTrue(KnownTagCodec.isUnslotted(unslotted)); + } + + private static final int FIRST_STORED_SERIAL_PLUS_7 = KnownTagCodec.FIRST_STORED_SERIAL + 7; +} 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..712b2281eee --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapDenseForkedTest.java @@ -0,0 +1,284 @@ +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.assertTrue; + +import datadog.trace.bootstrap.instrumentation.api.Tags; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Exercises the dense known-tag store with a LIVE resolver. Registration ({@link KnownTagCodec}) is + * a global static with no un-register, so this lives in a {@code ForkedTest} (isolated JVM) to keep + * dense routing from leaking into the bucket-only tests in the shared JVM. The dense store is + * dormant in production (no resolver) — this is where it actually executes. + * + *

    Stored tags (globalSerial ≥ {@code FIRST_STORED_SERIAL}) route to the dense store; reserved + * tags (e.g. {@code error}) and arbitrary tags stay in the hash buckets. Behavior must be + * observationally identical to the bucket store. + */ +class TagMapDenseForkedTest { + + static { + // Dense routing is a const-folded gate (KnownTagCodec.DENSE_STORE, captured from Config at + // KnownTagCodec's class-init). Set the flag before anything touches Config/KnownTagCodec so the + // gate reads true in this forked JVM. Registering the resolver alone no longer engages dense. + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + // stored (dense-routed) tags + static final String BASE_SERVICE = DDTags.BASE_SERVICE; + static final String COMPONENT = Tags.COMPONENT; + static final String DB_TYPE = Tags.DB_TYPE; + static final String HTTP_METHOD = Tags.HTTP_METHOD; // stored + intercepted + static final String DB_INSTANCE = Tags.DB_INSTANCE; + // arbitrary (bucket-routed) tags + static final String CUSTOM_A = "custom.tag.a"; + static final String CUSTOM_B = "custom.tag.b"; + + @BeforeAll + static void registerResolver() { + // Generated ids are compile-time constants (literal), so a constant reference is inlined and + // never triggers KnownTags.. init() forces class-load -> KnownTagCodec.register. + KnownTags.init(); + assertTrue(KnownTagCodec.isActive(), "resolver must be live for the dense store to engage"); + assertTrue(KnownTagCodec.DENSE_STORE, "dense store must be enabled in this forked JVM"); + assertTrue( + KnownTagCodec.routesToDense(KnownTagCodec.keyOf(BASE_SERVICE)), + "base_service routes dense"); + assertFalse( + KnownTagCodec.routesToDense(KnownTagCodec.keyOf(CUSTOM_A)), "custom tag stays in buckets"); + assertFalse( + KnownTagCodec.routesToDense(KnownTagCodec.keyOf(Tags.ERROR)), + "error is reserved, not stored"); + } + + private static TagMap map() { + return (TagMap) TagMap.create(); + } + + @Test + void knownTagRoundTripsThroughDenseStore() { + TagMap map = map(); + map.set(BASE_SERVICE, "billing"); + map.set(COMPONENT, "spring-web"); + + assertEquals("billing", map.getObject(BASE_SERVICE)); + assertEquals("spring-web", map.getString(COMPONENT)); + assertEquals("billing", map.getEntry(BASE_SERVICE).objectValue()); + assertTrue(map.containsKey(BASE_SERVICE)); + assertEquals(2, map.size()); + map.checkIntegrity(); + } + + @Test + void typedKnownValuesRoundTrip() { + TagMap map = map(); + map.set(DB_TYPE, "postgresql"); + map.set(HTTP_METHOD, "GET"); + map.set(Tags.PEER_PORT, 5432); + + assertEquals("postgresql", map.getString(DB_TYPE)); + assertEquals("GET", map.getString(HTTP_METHOD)); + assertEquals(5432, map.getInt(Tags.PEER_PORT)); + assertEquals(3, map.size()); + map.checkIntegrity(); + } + + @Test + void knownAndUnknownCoexist() { + TagMap map = map(); + map.set(BASE_SERVICE, "billing"); // dense + map.set(CUSTOM_A, "alpha"); // bucket + map.set(DB_TYPE, "h2"); // dense + map.set(CUSTOM_B, "beta"); // bucket + + assertEquals("billing", map.getObject(BASE_SERVICE)); + assertEquals("alpha", map.getObject(CUSTOM_A)); + assertEquals("h2", map.getObject(DB_TYPE)); + assertEquals("beta", map.getObject(CUSTOM_B)); + assertEquals(4, map.size()); + assertFalse(map.isEmpty()); + map.checkIntegrity(); + + Map collected = new HashMap<>(); + map.fillMap(collected); + assertEquals(4, collected.size()); + assertEquals("billing", collected.get(BASE_SERVICE)); + assertEquals("alpha", collected.get(CUSTOM_A)); + assertEquals("h2", collected.get(DB_TYPE)); + assertEquals("beta", collected.get(CUSTOM_B)); + } + + @Test + void overwriteKnownReplacesInPlace() { + TagMap map = map(); + map.set(COMPONENT, "first"); + assertEquals("first", map.getObject(COMPONENT)); + map.set(COMPONENT, "second"); + assertEquals("second", map.getObject(COMPONENT)); + assertEquals(1, map.size()); // overwrite, not append + map.checkIntegrity(); + } + + @Test + void removeKnownClearsIt() { + TagMap map = map(); + map.set(BASE_SERVICE, "billing"); + map.set(DB_TYPE, "h2"); + map.set(CUSTOM_A, "alpha"); + assertEquals(3, map.size()); + + TagMap.Entry removed = map.getAndRemove(BASE_SERVICE); + assertEquals("billing", removed.objectValue()); + assertNull(map.getObject(BASE_SERVICE)); + assertEquals("h2", map.getObject(DB_TYPE)); // sibling dense entry intact + assertEquals("alpha", map.getObject(CUSTOM_A)); + assertEquals(2, map.size()); + map.checkIntegrity(); + } + + @Test + void forEachAndIteratorEmitDenseAndBucketEntries() { + TagMap map = map(); + map.set(BASE_SERVICE, "billing"); + map.set(COMPONENT, "web"); + map.set(CUSTOM_A, "alpha"); + + Map viaForEach = new HashMap<>(); + map.forEach(reader -> viaForEach.put(reader.tag(), reader.objectValue())); + assertEquals(3, viaForEach.size()); + assertEquals("billing", viaForEach.get(BASE_SERVICE)); + assertEquals("web", viaForEach.get(COMPONENT)); + assertEquals("alpha", viaForEach.get(CUSTOM_A)); + + Map viaIterator = new HashMap<>(); + for (TagMap.EntryReader reader : map) { + viaIterator.put(reader.tag(), reader.objectValue()); + } + assertEquals(viaForEach, viaIterator); + } + + @Test + void copyPreservesDenseStore() { + TagMap map = map(); + map.set(BASE_SERVICE, "billing"); + map.set(CUSTOM_A, "alpha"); + + TagMap copy = (TagMap) map.copy(); + assertEquals("billing", copy.getObject(BASE_SERVICE)); + assertEquals("alpha", copy.getObject(CUSTOM_A)); + assertEquals(2, copy.size()); + + // independence: mutating the copy doesn't touch the original's dense store + copy.set(BASE_SERVICE, "shipping"); + assertEquals("shipping", copy.getObject(BASE_SERVICE)); + assertEquals("billing", map.getObject(BASE_SERVICE)); + copy.checkIntegrity(); + map.checkIntegrity(); + } + + @Test + void clearEmptiesDenseStore() { + TagMap map = map(); + map.set(BASE_SERVICE, "billing"); + map.set(CUSTOM_A, "alpha"); + map.clear(); + assertEquals(0, map.size()); + assertTrue(map.isEmpty()); + assertNull(map.getObject(BASE_SERVICE)); + map.checkIntegrity(); + } + + @Test + void putAllMergesDenseStore() { + TagMap src = map(); + src.set(BASE_SERVICE, "billing"); + src.set(DB_TYPE, "h2"); + src.set(CUSTOM_A, "alpha"); + + TagMap dst = map(); + dst.set(COMPONENT, "web"); // dense, distinct + dst.set(BASE_SERVICE, "old"); // dense, clobbered by src + dst.putAll((TagMap) src); + + assertEquals("billing", dst.getObject(BASE_SERVICE)); // src clobbers + assertEquals("h2", dst.getObject(DB_TYPE)); + assertEquals("web", dst.getObject(COMPONENT)); + assertEquals("alpha", dst.getObject(CUSTOM_A)); + assertEquals(4, dst.size()); + dst.checkIntegrity(); + } + + // ---- read-through union (dense parent + dense child) ---- + + private static TagMap frozenParent() { + TagMap parent = map(); + parent.set(BASE_SERVICE, "billing"); // dense + parent.set(COMPONENT, "web"); // dense + parent.set(CUSTOM_A, "alpha"); // bucket + parent.freeze(); + return parent; + } + + @Test + void childReadsThroughToParentDense() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set(DB_TYPE, "h2"); // child-only dense + child.set(CUSTOM_B, "beta"); // child-only bucket + + // inherited from parent + assertEquals("billing", child.getObject(BASE_SERVICE)); + assertEquals("web", child.getObject(COMPONENT)); + assertEquals("alpha", child.getObject(CUSTOM_A)); + // own + assertEquals("h2", child.getObject(DB_TYPE)); + assertEquals("beta", child.getObject(CUSTOM_B)); + // union size: 3 parent + 2 child + assertEquals(5, child.size()); + assertFalse(child.isEmpty()); + + Map union = new HashMap<>(); + child.forEach(reader -> union.put(reader.tag(), reader.objectValue())); + assertEquals(5, union.size()); + assertEquals("billing", union.get(BASE_SERVICE)); + assertEquals("h2", union.get(DB_TYPE)); + child.checkIntegrity(); + } + + @Test + void childDenseShadowsParentDense() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set(BASE_SERVICE, "shipping"); // shadows parent's dense base_service + + assertEquals("shipping", child.getObject(BASE_SERVICE)); // local wins + assertEquals("web", child.getObject(COMPONENT)); // still inherited + assertEquals(3, child.size()); // base_service counted once (shadowed, not doubled) + + Map union = new HashMap<>(); + child.forEach(reader -> union.put(reader.tag(), reader.objectValue())); + assertEquals(3, union.size()); + assertEquals("shipping", union.get(BASE_SERVICE)); // shadow value, parent suppressed + } + + @Test + void removingParentDenseKeyTombstonesIt() { + TagMap child = TagMap.createFromParent(frozenParent()); + + TagMap.Entry removed = child.getAndRemove(BASE_SERVICE); // parent-only dense key + assertEquals("billing", removed.objectValue()); // prior visible value was the parent's + assertNull(child.getObject(BASE_SERVICE)); // tombstoned: no read-through + assertEquals("web", child.getObject(COMPONENT)); // sibling still inherited + assertEquals(2, child.size()); // 3 parent - 1 tombstoned + + Map union = new HashMap<>(); + child.forEach(reader -> union.put(reader.tag(), reader.objectValue())); + assertEquals(2, union.size()); + assertFalse(union.containsKey(BASE_SERVICE)); + child.checkIntegrity(); + } +} 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..9ff3fd46419 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapDenseFuzzForkedTest.java @@ -0,0 +1,210 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.TagMapFuzzTest.MapAction; +import datadog.trace.api.TagMapFuzzTest.TestCase; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Supplier; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Fuzz test for the dense store under a LIVE resolver, across three key regimes. Reuses {@link + * TagMapFuzzTest}'s oracle machinery ({@code test(TestCase)} replays a random action sequence + * against a {@code HashMap}, verifying each step + {@code checkIntegrity}). + * + *

    Uses a synthetic prefix resolver ({@code known-N} -> stored / dense, anything else -> bucket) + * rather than the real {@link KnownTags}: it gives an UNBOUNDED known key space, so the dense array + * actually grows past its initial capacity and the linear scan gets long, and it lets each test pin + * the known/custom ratio. The three regimes exercise paths the mixed run alone would miss: + * + *

      + *
    • known-only — the all-dense map (dense growth, dense-only putAll/copy/clear/iterate + * with no bucket phase, knownCount-only size). + *
    • custom-only — confirms the dense branches stay inert when nothing resolves, even + * with a resolver registered. + *
    • mixed — both regions and their interaction. + *
    + * + *

    Forked (isolated JVM) because resolver registration is a global static with no un-register. + */ +class TagMapDenseFuzzForkedTest { + static { + // Dense routing is a const-folded gate (KnownTagCodec.DENSE_STORE, captured from Config at + // KnownTagCodec's class-init). Set the flag before anything touches Config/KnownTagCodec so the + // gate reads true in this forked JVM. Registering the resolver alone no longer engages dense. + 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; + + // unbounded synthetic key spaces — large enough to grow the dense array past cap-8 several times + static final int KNOWN_SPACE = 48; + static final int CUSTOM_SPACE = 48; + + enum Regime { + KNOWN_ONLY, + CUSTOM_ONLY, + MIXED + } + + /** + * Synthetic resolver: {@code known-N} -> stored id (serial = FIRST_STORED_SERIAL + N); else 0. + */ + static final KnownTagCodec.Resolver FUZZ_RESOLVER = + new KnownTagCodec.Resolver() { + @Override + public long keyOf(String name) { + if (name.startsWith("known-")) { + int n = Integer.parseInt(name.substring("known-".length())); + return KnownTagCodec.makeTagId(KnownTagCodec.FIRST_STORED_SERIAL + n, n); + } + return 0L; + } + + @Override + public String nameOf(long tagId) { + int serial = KnownTagCodec.serialNum(tagId); + return serial >= KnownTagCodec.FIRST_STORED_SERIAL + ? "known-" + (serial - KnownTagCodec.FIRST_STORED_SERIAL) + : null; + } + + @Override + public int slotCount() { + return 0; // positional unused + } + }; + + @BeforeAll + static void registerResolver() { + KnownTagCodec.register(FUZZ_RESOLVER); + assertTrue(KnownTagCodec.isActive(), "resolver must be live"); + assertTrue(KnownTagCodec.DENSE_STORE, "dense store must be enabled in this forked JVM"); + assertTrue(KnownTagCodec.routesToDense(KnownTagCodec.keyOf("known-0")), "known- routes dense"); + assertFalse( + KnownTagCodec.routesToDense(KnownTagCodec.keyOf("custom-0")), "custom- stays in buckets"); + // round-trip the synthetic encoding + long id = KnownTagCodec.keyOf("known-7"); + assertTrue("known-7".equals(KnownTagCodec.nameOf(id)), "name<->id round-trips"); + } + + @Test + void knownOnlyFuzz() { + runRegime(Regime.KNOWN_ONLY); + } + + @Test + void customOnlyFuzz() { + runRegime(Regime.CUSTOM_ONLY); + } + + @Test + void mixedFuzz() { + runRegime(Regime.MIXED); + } + + private static void runRegime(Regime regime) { + for (int i = 0; i < SINGLE_MAP_CASES; ++i) { + TagMapFuzzTest.test(generateTest(regime)); + } + for (int i = 0; i < MERGE_CASES; ++i) { + TagMap mapA = TagMapFuzzTest.test(generateTest(regime)); + TagMap mapB = TagMapFuzzTest.test(generateTest(regime)); + + HashMap hashA = new HashMap<>(mapA); + HashMap hashB = new HashMap<>(mapB); + + mapA.putAll(mapB); + hashA.putAll(hashB); + + TagMapFuzzTest.assertMapEquals(hashA, mapA); + } + } + + // --- action generation (mirrors TagMapFuzzTest.randomAction, regime-driven key pool) --- + + 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 known; + switch (regime) { + case KNOWN_ONLY: + known = true; + break; + case CUSTOM_ONLY: + known = false; + break; + default: + known = r.nextBoolean(); + } + return known ? "known-" + r.nextInt(KNOWN_SPACE) : "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(KNOWN_SPACE + 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/TagMapReadThroughTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java new file mode 100644 index 00000000000..14b040a8406 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapReadThroughTest.java @@ -0,0 +1,603 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * Read-through support, slice 1 (read path): a child {@link TagMap} with a frozen parent reads + * through to the parent on a local miss, while local entries shadow the parent (local-wins). + * Removal/tombstones and bulk (iteration/serialize) union come in later slices. + */ +class TagMapReadThroughTest { + + private static TagMap frozenParent() { + TagMap parent = TagMap.create(); + parent.set("a", "parent-a"); + parent.set("b", "parent-b"); + parent.freeze(); + return parent; + } + + @Test + void readsThroughToParentOnMiss() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + assertEquals("parent-a", child.getString("a")); // miss locally -> read through + assertEquals("parent-b", child.getString("b")); + assertEquals("child-c", child.getString("c")); // local + assertNull(child.getString("missing")); + assertTrue(child.containsKey("a")); + assertFalse(child.containsKey("missing")); + } + + @Test + void localEntryShadowsParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // same key as parent + + assertEquals("child-b", child.getString("b")); // local wins + assertEquals("parent-a", child.getString("a")); // parent still visible + } + + @Test + void estimateSizeIsUpperBound() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + // true union = {a, b, c} = 3; estimate over-counts the shadowed "b": local 2 + parent 2 = 4 + assertEquals(4, child.estimateSize()); + assertTrue(child.estimateSize() >= 3, "estimateSize must be an upper bound on the true size"); + } + + @Test + void emptinessSemantics() { + TagMap emptyOverEmpty = TagMap.createFromParent(TagMap.create().freeze()); + assertTrue(emptyOverEmpty.isEmpty()); + assertTrue(emptyOverEmpty.isDefinitelyEmpty()); + + TagMap emptyOverNonEmpty = TagMap.createFromParent(frozenParent()); + assertFalse(emptyOverNonEmpty.isEmpty(), "a non-empty parent makes the map non-empty"); + assertFalse(emptyOverNonEmpty.isDefinitelyEmpty()); + + assertTrue((TagMap.create()).isDefinitelyEmpty()); + } + + @Test + void parentMustBeFrozen() { + TagMap mutableParent = TagMap.create(); + assertThrows(IllegalStateException.class, () -> TagMap.createFromParent(mutableParent)); + } + + @Test + void emptyParentIsDroppedNotAttached() { + TagMap emptyFrozen = TagMap.create(); + emptyFrozen.freeze(); + + TagMap overEmpty = TagMap.createFromParent(emptyFrozen); + // an empty frozen parent contributes nothing and never will -> dropped, no read-through cost + assertNull(overEmpty.parent, "empty parent should be dropped"); + assertTrue(overEmpty.isDefinitelyEmpty()); + overEmpty.set("x", "x-val"); // still a normal mutable map + assertEquals("x-val", overEmpty.getString("x")); + + // a non-empty parent is still attached + TagMap overNonEmpty = TagMap.createFromParent(frozenParent()); + assertNotNull(overNonEmpty.parent, "non-empty parent must be attached"); + assertEquals("parent-a", overNonEmpty.getString("a")); + } + + // --- slice 2: removal / tombstones --- + + @Test + void removingParentKeyHidesItFromChildButNotFromParent() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + + assertEquals("parent-a", child.getString("a")); // visible before removal + child.remove("a"); + + assertNull(child.getString("a")); // tombstoned: no longer reads through + assertFalse(child.containsKey("a")); + assertEquals("parent-b", child.getString("b")); // other parent keys unaffected + assertEquals("parent-a", parent.getString("a")); // frozen parent untouched + } + + @Test + void removeReturnsPriorVisibleValueViaParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + + // Map.remove contract: the key was present (via read-through), so removal reports it. + assertTrue(child.remove("a"), "removing a parent-exposed key should report it was present"); + assertNull(child.getString("a")); + } + + @Test + void reSettingARemovedKeyRestoresVisibility() { + TagMap child = TagMap.createFromParent(frozenParent()); + + child.remove("a"); + assertNull(child.getString("a")); + + child.set("a", "child-a"); // re-set clears the tombstone + assertEquals("child-a", child.getString("a")); + } + + @Test + void removingAKeyThatIsBothLocalAndParentHidesBoth() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + + assertEquals("child-b", child.getString("b")); + child.remove("b"); + + assertNull(child.getString("b"), "removal must hide both the local entry and the parent's"); + assertEquals("parent-b", frozenParent().getString("b")); // parent still has it + } + + // --- slice 3a: bulk forEach union + exact size/isEmpty --- + + private static Map collect(TagMap map) { + Map out = new HashMap<>(); + map.forEach(e -> out.put(e.tag(), e.objectValue())); + return out; + } + + @Test + void forEachEmitsDedupedUnionLocalWins() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map u = collect(child); + assertEquals(3, u.size(), "union {a, b, c} with b deduped"); + assertEquals("parent-a", u.get("a")); // read-through + assertEquals("child-b", u.get("b")); // local wins (no duplicate emit) + assertEquals("child-c", u.get("c")); + } + + @Test + void forEachSkipsTombstonedParentKeys() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + child.remove("a"); // tombstone parent's "a" + + Map u = collect(child); + assertEquals(2, u.size()); + assertFalse(u.containsKey("a")); + assertEquals("parent-b", u.get("b")); + assertEquals("child-c", u.get("c")); + } + + @Test + void biConsumerForEachAlsoEmitsUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + Map out = new HashMap<>(); + child.forEach(out, (m, e) -> m.put(e.tag(), e.objectValue())); // non-capturing: alloc-free path + assertEquals(3, out.size()); + assertEquals("parent-a", out.get("a")); + assertEquals("child-c", out.get("c")); + } + + @Test + void sizeIsExactUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows + child.set("c", "child-c"); + assertEquals(3, child.size()); // {a, b, c} — b deduped, not 4 + + child.remove("a"); + assertEquals(2, child.size()); // {b, c} + } + + @Test + void isEmptyExactWhenAllParentKeysTombstonedAndNoLocal() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + assertFalse(child.isEmpty()); + + child.remove("a"); + child.remove("b"); + assertTrue(child.isEmpty(), "all parent keys tombstoned and no local entries -> empty"); + assertEquals(0, child.size()); + } + + // --- slice 3b: pull-based iterators / collection views --- + + @Test + void iteratorEmitsDedupedUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + Map u = new HashMap<>(); + Iterator it = child.iterator(); + while (it.hasNext()) { + TagMap.EntryReader e = it.next(); + u.put(e.tag(), e.objectValue()); + } + assertEquals(3, u.size()); + assertEquals("parent-a", u.get("a")); + assertEquals("child-b", u.get("b")); // local wins, emitted once + assertEquals("child-c", u.get("c")); + } + + @Test + void keySetReflectsUnionAndTombstones() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + Set keys = child.keySet(); + assertEquals(3, keys.size()); // a, b, c + assertTrue(keys.contains("a")); + assertTrue(keys.contains("c")); + + child.remove("a"); + assertEquals(2, child.keySet().size()); + assertFalse(child.keySet().contains("a")); + } + + @Test + void valuesAndEntrySetReflectUnion() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); // shadows parent "b" + + assertEquals(2, child.entrySet().size()); // {a, b} — b deduped + assertTrue(child.values().contains("child-b")); // local-won value + assertTrue(child.values().contains("parent-a")); + assertFalse(child.values().contains("parent-b"), "shadowed parent value must not appear"); + } + + // --- slice 3c: putAll from a read-through source copies the visible union, not just locals --- + + @Test + void putAllFromReadThroughSourceCopiesFullVisibleUnion() { + TagMap source = TagMap.createFromParent(frozenParent()); // parent {a, b} + source.set("b", "child-b"); // shadows parent b + source.set("c", "child-c"); + + TagMap dest = TagMap.create(); // empty -> putAllIntoEmptyMap path + dest.putAll(source); + + // the parent-visible "a" must land too, not just source's local entries + assertEquals(3, dest.size()); + assertEquals("parent-a", dest.getString("a")); + assertEquals("child-b", dest.getString("b")); // local-won value, deduped + assertEquals("child-c", dest.getString("c")); + } + + @Test + void putAllMergeFromReadThroughSourceCopiesVisibleUnion() { + TagMap source = TagMap.createFromParent(frozenParent()); // {a, b} + + TagMap dest = TagMap.create(); + dest.set("z", "dest-z"); // dest non-empty -> putAllMerge path + + dest.putAll(source); + assertEquals(3, dest.size()); // {a, b, z} + assertEquals("parent-a", dest.getString("a")); + assertEquals("parent-b", dest.getString("b")); + assertEquals("dest-z", dest.getString("z")); + } + + @Test + void putAllFromReadThroughSourceHonorsTombstones() { + TagMap source = TagMap.createFromParent(frozenParent()); + source.remove("a"); // tombstone parent's "a" + + TagMap dest = TagMap.create(); + dest.putAll(source); + + assertEquals(1, dest.size()); + assertFalse(dest.containsKey("a"), "tombstoned key must not be copied"); + assertEquals("parent-b", dest.getString("b")); + } + + // --- slice 4: behavior-identical to a copy-down / flat map --- + + @Test + void copyIsObservationallyIdentical() { + TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} + child.set("b", "child-b"); // shadows parent "b" + child.set("c", "child-c"); + + TagMap copy = child.copy(); + assertEquals(child.size(), copy.size()); + assertEquals("parent-a", copy.getString("a")); // copy still reads through + assertEquals("child-b", copy.getString("b")); + assertEquals("child-c", copy.getString("c")); + assertEquals(collect(child), collect(copy)); // same union + } + + @Test + void copyIsIndependentlyMutable() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + TagMap copy = child.copy(); + copy.set("c", "copy-c"); // mutate copy's local + copy.remove("a"); // tombstone on copy only + + assertEquals("child-c", child.getString("c"), "original unaffected by copy mutation"); + assertEquals("parent-a", child.getString("a"), "original still reads through a"); + assertEquals("copy-c", copy.getString("c")); + assertNull(copy.getString("a")); + } + + @Test + void copyPreservesTombstones() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("a"); // tombstone "a" + + TagMap copy = child.copy(); + assertNull(copy.getString("a"), "tombstone must carry into the copy"); + assertEquals("parent-b", copy.getString("b")); + } + + /** The contract that lets the consumer flip mergedTracerTags to a parent. */ + @Test + void readThroughMatchesAnEquivalentFlatMap() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("b", "child-b"); + child.set("c", "child-c"); + + TagMap flat = TagMap.create(); + flat.set("a", "parent-a"); + flat.set("b", "child-b"); + flat.set("c", "child-c"); + + assertEquals(flat.size(), child.size()); + assertEquals(collect(flat), collect(child)); + assertEquals(flat.keySet(), child.keySet()); + for (String k : new String[] {"a", "b", "c", "missing"}) { + assertEquals(flat.getString(k), child.getString(k), "mismatch for key " + k); + } + } + + @Test + void immutableCopyOfReadThroughIsFrozenAndStillReadsThrough() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("c", "child-c"); + + TagMap frozen = child.immutableCopy(); + assertTrue(frozen.isFrozen()); + assertEquals("parent-a", frozen.getString("a")); // union preserved + assertEquals("child-c", frozen.getString("c")); + assertThrows(IllegalStateException.class, () -> frozen.set("x", "y")); // frozen blocks writes + } + + // --- slice 5: multi-level chains (baggage-style layering over more than one frozen parent) --- + + /** + * Builds a 3-level chain leaf -> mid -> grandparent (both ancestors frozen) and returns the + * leaf. Visible union, nearest-level-wins: {a=gp-a, b=mid-b, c=leaf-c, d=mid-d, e=leaf-e}. + */ + private static TagMap threeLevelLeaf() { + TagMap grandparent = TagMap.create(); + grandparent.set("a", "gp-a"); + grandparent.set("b", "gp-b"); + grandparent.set("c", "gp-c"); + grandparent.freeze(); + + TagMap mid = TagMap.createFromParent(grandparent); + mid.set("b", "mid-b"); // shadows grandparent b + mid.set("d", "mid-d"); + mid.freeze(); + + TagMap leaf = TagMap.createFromParent(mid); + leaf.set("c", "leaf-c"); // shadows grandparent c (mid doesn't define c) + leaf.set("e", "leaf-e"); + return leaf; + } + + @Test + void getWalksTheWholeChainNearestWins() { + TagMap leaf = threeLevelLeaf(); + assertEquals("gp-a", leaf.getString("a")); // only in grandparent (two levels up) + assertEquals("mid-b", leaf.getString("b")); // mid shadows grandparent + assertEquals("leaf-c", leaf.getString("c")); // leaf shadows grandparent + assertEquals("mid-d", leaf.getString("d")); + assertEquals("leaf-e", leaf.getString("e")); + assertNull(leaf.getString("missing")); + } + + @Test + void sizeIsExactUnionAcrossChain() { + assertEquals(5, threeLevelLeaf().size()); // {a, b, c, d, e}, shadowed duplicates deduped + } + + @Test + void forEachEmitsDedupedUnionAcrossChain() { + Map u = collect(threeLevelLeaf()); + assertEquals(5, u.size()); + assertEquals("gp-a", u.get("a")); + assertEquals("mid-b", u.get("b")); // nearest ancestor wins over grandparent + assertEquals("leaf-c", u.get("c")); // leaf wins + assertEquals("mid-d", u.get("d")); + assertEquals("leaf-e", u.get("e")); + } + + @Test + void iteratorEmitsDedupedUnionAcrossChain() { + Map u = new HashMap<>(); + Iterator it = threeLevelLeaf().iterator(); + while (it.hasNext()) { + TagMap.EntryReader e = it.next(); + u.put(e.tag(), e.objectValue()); + } + assertEquals(5, u.size()); + assertEquals("gp-a", u.get("a")); + assertEquals("mid-b", u.get("b")); + assertEquals("leaf-c", u.get("c")); + } + + @Test + void keySetReflectsChainUnion() { + Set keys = threeLevelLeaf().keySet(); + assertEquals(5, keys.size()); + for (String k : new String[] {"a", "b", "c", "d", "e"}) { + assertTrue(keys.contains(k), "missing key " + k); + } + } + + @Test + void leafTombstoneHidesGrandparentOnlyKey() { + TagMap leaf = threeLevelLeaf(); + leaf.remove("a"); // "a" lives only in the grandparent, two levels up + assertNull(leaf.getString("a")); + assertFalse(leaf.containsKey("a")); + assertFalse(leaf.keySet().contains("a")); + assertEquals(4, leaf.size()); // {b, c, d, e} + } + + @Test + void intermediateAncestorTombstoneIsHonoredByBulkViews() { + // mid removes an inherited grandparent key, THEN is frozen and reused as a parent. A non-leaf + // level can therefore carry its own tombstones. Point lookups recurse through mid's tombstone + // (correct); the bulk views must agree and not re-emit the key. + TagMap grandparent = TagMap.create(); + grandparent.set("a", "gp-a"); + grandparent.set("b", "gp-b"); + grandparent.freeze(); + + TagMap mid = TagMap.createFromParent(grandparent); + mid.remove("a"); // tombstone an inherited key before freezing + mid.freeze(); + + TagMap leaf = TagMap.createFromParent(mid); + + // point lookups (recurse -> already correct) + assertNull(leaf.getString("a")); + assertFalse(leaf.containsKey("a")); + assertEquals("gp-b", leaf.getString("b")); + + // bulk views must agree: mid's tombstone hides grandparent's "a" + assertEquals(1, leaf.size()); + assertFalse(leaf.keySet().contains("a")); + + Map viaForEach = collect(leaf); + assertEquals(1, viaForEach.size()); + assertFalse(viaForEach.containsKey("a")); + assertEquals("gp-b", viaForEach.get("b")); + + Map viaIterator = new HashMap<>(); + Iterator it = leaf.iterator(); + while (it.hasNext()) { + TagMap.EntryReader e = it.next(); + viaIterator.put(e.tag(), e.objectValue()); + } + assertEquals(1, viaIterator.size()); + assertFalse(viaIterator.containsKey("a")); + } + + @Test + void chainReadThroughMatchesEquivalentFlatMap() { + TagMap leaf = threeLevelLeaf(); + + TagMap flat = TagMap.create(); + flat.set("a", "gp-a"); + flat.set("b", "mid-b"); + flat.set("c", "leaf-c"); + flat.set("d", "mid-d"); + flat.set("e", "leaf-e"); + + assertEquals(flat.size(), leaf.size()); + assertEquals(collect(flat), collect(leaf)); + assertEquals(flat.keySet(), leaf.keySet()); + for (String k : new String[] {"a", "b", "c", "d", "e", "missing"}) { + assertEquals(flat.getString(k), leaf.getString(k), "mismatch for key " + k); + } + } + + // --- slice 6: put/getAndSet report the prior visible value, including inherited --- + + @Test + void putReturnsInheritedParentValueAsPrior() { + TagMap child = TagMap.createFromParent(frozenParent()); // parent {a, b} + Object prior = child.put("a", "child-a"); // "a" exists only in the parent + assertEquals("parent-a", prior, "put must report the inherited value as the previous mapping"); + assertEquals("child-a", child.getString("a")); // new value stored locally + } + + @Test + void putReturnsNullForAKeyInNeitherLocalNorParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + assertNull(child.put("brand-new", "v")); + } + + @Test + void putReturnsLocalPriorWhenShadowingParent() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.set("a", "local-a"); // local now shadows the parent + assertEquals("local-a", child.put("a", "local-a2"), "the local prior wins over the parent's"); + } + + @Test + void putAfterRemoveReportsNoPriorNotTheParentValue() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("a"); // tombstone the parent's "a": no longer visible + assertNull(child.put("a", "child-a"), "a tombstoned key had no visible prior value"); + assertEquals("child-a", child.getString("a")); + } + + @Test + void getAndSetReturnsInheritedEntryAsPrior() { + TagMap child = TagMap.createFromParent(frozenParent()); + TagMap.Entry prior = child.getAndSet("b", "child-b"); + assertEquals("parent-b", prior.objectValue()); + } + + @Test + void setDoesNotReportPriorButStillClearsTombstone() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.remove("b"); // tombstone + child.set("b", "child-b"); // void set: no prior lookup, but must clear the tombstone + assertEquals("child-b", child.getString("b")); + } + + // --- slice 7: clear() removes inherited mappings too (detaches the parent) --- + + @Test + void clearRemovesInheritedMappingsAndDetachesParent() { + TagMap child = TagMap.createFromParent(frozenParent()); // {a, b} + child.set("c", "child-c"); + + child.clear(); + + assertTrue(child.isEmpty(), "clear must remove local AND inherited mappings"); + assertEquals(0, child.size()); + assertNull(child.getString("a")); // inherited no longer visible + assertNull(child.getString("c")); + assertFalse(child.containsKey("a")); + } + + @Test + void clearDoesNotAffectTheFrozenParent() { + TagMap parent = frozenParent(); + TagMap child = TagMap.createFromParent(parent); + child.clear(); + assertEquals("parent-a", parent.getString("a"), "the shared frozen parent is untouched"); + } + + @Test + void putAfterClearBehavesAsAPlainMap() { + TagMap child = TagMap.createFromParent(frozenParent()); + child.clear(); + child.set("x", "x-val"); + assertEquals(1, child.size()); + assertEquals("x-val", child.getString("x")); + assertNull(child.getString("a"), "no read-through after clear detached the parent"); + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java b/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java new file mode 100644 index 00000000000..9a3b1db2571 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/StringIndexFootprintTest.java @@ -0,0 +1,88 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; +import org.openjdk.jol.info.GraphLayout; + +/** + * Retained-footprint comparison (JOL) for {@link StringIndex} vs the JDK set representations, over + * a fixed read-only string set. Footprint is deterministic, so this is safe to run under load + * (unlike the throughput benchmarks). + * + *

    All structures hold the same String instances, so the shared strings cancel out and the + * differences reflect structural overhead. We report total retained bytes and the overhead above a + * plain {@code String[]} (which is just the strings + a reference array). {@code Set.copyOf} yields + * the JDK's compact {@code SetN} only on Java 10+ (it falls back to {@code HashSet} pre-10), so the + * copyOf row is only meaningful on a 10+ test JVM. + * + *

    The one robust cross-JVM invariant we assert is that {@code StringIndex} is lighter than + * {@code HashSet} (no per-element {@code Node} objects). The {@code StringIndex} vs {@code SetN} + * comparison is left as reported data rather than an assertion: {@code StringIndex} caches an + * {@code int[]} of hashes that {@code SetN} does not, so which one wins on bytes is genuinely worth + * measuring. + * + *

    Measured retained bytes (Java 17, JOL estimate mode — relative ordering reliable, exact bytes + * approximate): + * + *

    {@code
    + * n      array   hashSet  treeSet   copyOf  stringIndex
    + * 8        496      864      848      552      760
    + * 32      1936     3168     3152     2088     2872
    + * 128     7696    12384    12368     8232    11320
    + * }
    + * + * Finding: {@code StringIndex} is ~9% lighter than {@code HashSet}/{@code TreeSet} (no per-element + * {@code Node} objects), but {@code Set.copyOf} ({@code SetN}) is the most compact by a wide margin + * (~27% under {@code StringIndex} at n=128) — {@code StringIndex} pays for its cached {@code int[]} + * hashes and 2x-oversized {@code String[]}. So {@code StringIndex}'s edge over {@code SetN} is + * speed and the {@code indexOf}->parallel-array capability, not footprint. + */ +class StringIndexFootprintTest { + + static String[] elements(int n) { + String[] a = new String[n]; + for (int i = 0; i < n; ++i) { + a[i] = "element-key-" + i; + } + return a; + } + + static long bytes(Object root) { + return GraphLayout.parseInstance(root).totalSize(); + } + + @Test + void footprintComparison() { + System.out.printf( + "%-6s %12s %12s %12s %12s %12s%n", + "n", "array", "hashSet", "treeSet", "copyOf", "stringIndex"); + System.out.printf( + "%-6s %12s %12s %12s %12s %12s (overhead above array)%n", "", "", "", "", "", ""); + + for (int n : new int[] {8, 32, 128}) { + String[] el = elements(n); + + long array = bytes((Object) el); // baseline: strings + reference array + long hashSet = bytes(new HashSet<>(Arrays.asList(el))); + long treeSet = bytes(new TreeSet<>(Arrays.asList(el))); + Set copy = CollectionUtils.tryMakeImmutableSet(Arrays.asList(el)); + long copyOf = bytes(copy); + long stringIndex = bytes(StringIndex.of(el)); + + System.out.printf( + "%-6d %12d %12d %12d %12d %12d%n", n, array, hashSet, treeSet, copyOf, stringIndex); + System.out.printf( + "%-6s %12s %12d %12d %12d %12d%n", + "", "", hashSet - array, treeSet - array, copyOf - array, stringIndex - array); + + // Robust cross-JVM invariant: no per-element Node objects -> lighter than HashSet. + assertTrue( + stringIndex < hashSet, "StringIndex should retain fewer bytes than HashSet at n=" + n); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java new file mode 100644 index 00000000000..987ccc8b3ba --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/StringIndexTest.java @@ -0,0 +1,203 @@ +package datadog.trace.util; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.util.StringIndex.Data; +import datadog.trace.util.StringIndex.EmbeddingSupport; +import org.junit.jupiter.api.Test; + +class StringIndexTest { + + @Test + void hash_spread_and_zeroSentinel() { + // "".hashCode() == 0 -> remapped to the non-zero sentinel so 0 can mean "empty slot" + assertEquals(0xDD06, EmbeddingSupport.hash("")); + + int raw = "foo".hashCode(); + assertEquals(raw ^ (raw >>> 16), EmbeddingSupport.hash("foo")); + assertNotEquals(0, EmbeddingSupport.hash("foo")); + } + + @Test + void capacityFor_isPow2_andAtLeastDoubled() { + assertEquals(2, EmbeddingSupport.capacityFor(0)); // empty set -> minimal table + assertEquals(2, EmbeddingSupport.capacityFor(1)); // >= 2x, smallest power of two + assertEquals(8, EmbeddingSupport.capacityFor(3)); // ceil(3/0.5)=6 -> 8 + assertEquals(8, EmbeddingSupport.capacityFor(4)); // ceil(4/0.5)=8 -> 8 (was 16: tightened) + assertEquals(64, EmbeddingSupport.capacityFor(16, EmbeddingSupport.LOW_LOAD_FACTOR)); // 4x + } + + @Test + void capacityFor_rejectsBadArgs() { + assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(-1)); + assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(4, 0f)); + assertThrows(IllegalArgumentException.class, () -> EmbeddingSupport.capacityFor(4, 1f)); + } + + @Test + void instance_contains_internedAndCopy_andMiss() { + StringIndex set = StringIndex.of("foo", "bar", "baz"); + + assertEquals(8, set.numSlots()); // 3 names -> capacityFor(3) == 8 + + assertTrue(set.contains("foo")); // interned literal -> == fast path in eq + assertTrue(set.contains(new String("bar"))); // non-interned -> .equals path + assertFalse(set.contains("nope")); + + assertTrue(set.indexOf("baz") >= 0); + assertEquals(-1, set.indexOf("nope")); + } + + @Test + void support_create_then_indexOf() { + Data d = EmbeddingSupport.create("x", "y"); + + int slot = EmbeddingSupport.indexOf(d.hashes, d.names, "x"); // 3-arg overload computes the hash + assertTrue(slot >= 0); + assertEquals("x", d.names[slot]); + + assertEquals(-1, EmbeddingSupport.indexOf(d.hashes, d.names, "q")); + } + + /** Controlled hashes force collision, linear-probe wraparound, and the already-present path. */ + @Test + void put_and_indexOf_collisionAndWraparound() { + int[] hashes = new int[4]; // mask = 3 + String[] names = new String[4]; + + assertEquals(3, EmbeddingSupport.put(hashes, names, "a", 7)); // 7 & 3 == 3 + assertEquals( + 0, EmbeddingSupport.put(hashes, names, "b", 7)); // collides at 3, probes (3+1)&3 == 0 + assertEquals( + 3, EmbeddingSupport.put(hashes, names, "a", 7)); // already present -> existing slot + + assertEquals(3, EmbeddingSupport.indexOf(hashes, names, "a", 7)); // direct hit + assertEquals( + 0, EmbeddingSupport.indexOf(hashes, names, "b", 7)); // hit after collision + wraparound + assertEquals( + -1, + EmbeddingSupport.indexOf(hashes, names, "c", 7)); // miss after probing 3 -> 0 -> 1(empty) + assertEquals( + -1, EmbeddingSupport.indexOf(hashes, names, "z", 6)); // 6 & 3 == 2, empty -> immediate miss + } + + @Test + void put_throwsWhenFull() { + int[] hashes = new int[2]; // mask = 1 + String[] names = new String[2]; + + EmbeddingSupport.put(hashes, names, "a", 4); // 4 & 1 == 0 + EmbeddingSupport.put(hashes, names, "b", 5); // 5 & 1 == 1 + + // both slots occupied, no match -> probe exhausts -> throw + assertThrows(IllegalStateException.class, () -> EmbeddingSupport.put(hashes, names, "c", 6)); + } + + /** The documented usage: build a StringIndex, attach a parallel payload indexed by slot. */ + @Test + void parallelPayloadBySlot() { + String[] names = {"a", "b", "c"}; + Data d = EmbeddingSupport.create(names); + + long[] ids = new long[d.names.length]; + for (int j = 0; j < names.length; j++) { + ids[EmbeddingSupport.indexOf(d.hashes, d.names, names[j])] = j + 1L; + } + + assertEquals(1L, ids[EmbeddingSupport.indexOf(d.hashes, d.names, "a")]); + assertEquals(2L, ids[EmbeddingSupport.indexOf(d.hashes, d.names, "b")]); + assertEquals(3L, ids[EmbeddingSupport.indexOf(d.hashes, d.names, "c")]); + } + + @Test + void mapIntValues_slotAligned_andLookup() { + StringIndex idx = StringIndex.of("a", "b", "c"); + // 1-based ids; 0 stays the empty-slot / not-found sentinel. + int[] ids = idx.mapIntValues(s -> s.charAt(0) - 'a' + 1); + assertEquals(idx.numSlots(), ids.length); // sized to the table, not the name count + + assertEquals(1, idx.lookup(ids, "a")); + assertEquals(2, idx.lookup(ids, "b")); + assertEquals(3, idx.lookup(ids, "c")); + assertEquals(0, idx.lookup(ids, "z")); // miss -> 0 + assertEquals(-1, idx.lookupOrDefault(ids, "z", -1)); // miss -> supplied default + } + + @Test + void mapLongValues_slotAligned_andLookup() { + Data d = EmbeddingSupport.create("a", "b", "c"); + long[] vals = EmbeddingSupport.mapLongValues(d.names, s -> s.charAt(0) - 'a' + 1L); + + assertEquals(1L, EmbeddingSupport.lookup(d.hashes, d.names, vals, "a")); + assertEquals(3L, EmbeddingSupport.lookup(d.hashes, d.names, vals, "c")); + assertEquals(0L, EmbeddingSupport.lookup(d.hashes, d.names, vals, "z")); // miss -> 0 + assertEquals(-1L, EmbeddingSupport.lookupOrDefault(d.hashes, d.names, vals, "z", -1L)); + } + + @Test + void mapValues_objects_typedArray_andLookup() { + StringIndex idx = StringIndex.of("a", "bb", "ccc"); + Integer[] lengths = idx.mapValues(Integer.class, String::length); + + // Class drives a real Integer[], not an Object[]. + assertEquals(Integer[].class, lengths.getClass()); + + assertEquals(Integer.valueOf(1), idx.lookup(lengths, "a")); + assertEquals(Integer.valueOf(3), idx.lookup(lengths, "ccc")); + assertNull(idx.lookup(lengths, "z")); // miss -> null + assertEquals(Integer.valueOf(-1), idx.lookupOrDefault(lengths, "z", -1)); + } + + @Test + void support_mapValues_objects_sizedToSlots_emptyStayNull() { + Data d = EmbeddingSupport.create("a", "b", "c"); + String[] tagged = EmbeddingSupport.mapValues(d.names, String.class, s -> s + "!"); + + assertEquals(d.names.length, tagged.length); // sized to the table + int nonNull = 0; + for (String s : tagged) { + if (s != null) { + nonNull++; + } + } + assertEquals(3, nonNull); // only the placed names map; unfilled slots stay null + + assertEquals("a!", EmbeddingSupport.lookup(d.hashes, d.names, tagged, "a")); + assertEquals("dflt", EmbeddingSupport.lookupOrDefault(d.hashes, d.names, tagged, "z", "dflt")); + } + + @Test + void instance_lookup_delegatesToSupportArrays() { + StringIndex idx = StringIndex.of("x", "y"); + int[] ids = idx.mapIntValues(s -> "x".equals(s) ? 7 : 9); + + assertEquals(7, idx.lookup(ids, "x")); + assertEquals(9, idx.lookup(ids, "y")); + assertEquals(0, idx.lookup(ids, "missing")); + assertEquals(42, idx.lookupOrDefault(ids, "missing", 42)); + } + + @Test + void instance_longValues_mapAndLookup() { + StringIndex idx = StringIndex.of("a", "b", "c"); + long[] vals = idx.mapLongValues(s -> s.charAt(0) - 'a' + 1L); + assertEquals(idx.numSlots(), vals.length); // sized to the table, not the name count + + assertEquals(1L, idx.lookup(vals, "a")); + assertEquals(3L, idx.lookup(vals, "c")); + assertEquals(0L, idx.lookup(vals, "z")); // miss -> 0 + assertEquals(2L, idx.lookupOrDefault(vals, "b", -1L)); // hit + assertEquals(-1L, idx.lookupOrDefault(vals, "z", -1L)); // miss -> supplied default + } + + @Test + void support_numSlots_matchesTableSize() { + Data d = EmbeddingSupport.create("a", "b", "c"); + assertEquals(d.hashes.length, EmbeddingSupport.numSlots(d.hashes)); + } +} diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 850c6055d58..4c5bf4c8b0a 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -5641,6 +5641,14 @@ "aliases": [] } ], + "DD_TRACE_EXPERIMENTAL_DENSE_TAGS_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "false", + "aliases": [] + } + ], "DD_TRACE_EXPERIMENTAL_FEATURES_ENABLED": [ { "version": "A",