From b294856343ae4438f8226eefc10ff30087d37c0e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 23 Jul 2026 08:59:26 -0400 Subject: [PATCH 1/4] Add per-operation self-tuning dense-store sizing (SizingHint) Sizes each span's dense TagMap store from a per-operation hint instead of a generic default, then feeds the actual known-tag count back on finish so the hint converges to the operation's real high-water mark. - FlatHashtable: open-addressed find-or-create over self-contained entries; static-polymorphism via a concrete-typed Helper singleton so the JIT devirtualizes/inlines hash/matches/create per call site. - SizingHint / SizingHelper: opaque per-operation hint + its String-key helper. - SizingHintTable: process-wide, pure-static two-lane (entry vs child) registry keyed by operation name; fixed-capacity + deliberately lock-free/racy (a lost update or double-mint only mis-sizes an array, never corrupts tag data). - CoreTracer.buildSpanContext resolves the hint by operationName + entry-ness and hands it to DDSpanContext, which sizes its TagMap and records the size back via DDSpan.finishAndAddToTrace. - Tests: FlatHashtableTest, SizingHintTableTest (incl. self-tuning + content-keying). - Benchmarks: FlatHashtableBenchmark; DenseStoreAllocBenchmark buildMapSized arm. Co-Authored-By: Claude Opus 4.8 --- dd-trace-core/build.gradle | 3 + .../java/datadog/trace/core/CoreTracer.java | 16 +- .../main/java/datadog/trace/core/DDSpan.java | 1 + .../datadog/trace/core/DDSpanContext.java | 33 +++- .../trace/api/DenseStoreAllocBenchmark.java | 18 ++ .../trace/util/FlatHashtableBenchmark.java | 97 +++++++++++ .../java/datadog/trace/api/SizingHelper.java | 27 +++ .../java/datadog/trace/api/SizingHint.java | 49 ++++++ .../datadog/trace/api/SizingHintTable.java | 98 +++++++++++ .../main/java/datadog/trace/api/TagMap.java | 32 +++- .../datadog/trace/util/FlatHashtable.java | 152 +++++++++++++++++ .../trace/api/SizingHintTableTest.java | 105 ++++++++++++ .../datadog/trace/util/FlatHashtableTest.java | 158 ++++++++++++++++++ 13 files changed, 782 insertions(+), 7 deletions(-) create mode 100644 internal-api/src/jmh/java/datadog/trace/util/FlatHashtableBenchmark.java create mode 100644 internal-api/src/main/java/datadog/trace/api/SizingHelper.java create mode 100644 internal-api/src/main/java/datadog/trace/api/SizingHint.java create mode 100644 internal-api/src/main/java/datadog/trace/api/SizingHintTable.java create mode 100644 internal-api/src/main/java/datadog/trace/util/FlatHashtable.java create mode 100644 internal-api/src/test/java/datadog/trace/api/SizingHintTableTest.java create mode 100644 internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java diff --git a/dd-trace-core/build.gradle b/dd-trace-core/build.gradle index 59018a6973b..72a282eed7a 100644 --- a/dd-trace-core/build.gradle +++ b/dd-trace-core/build.gradle @@ -134,6 +134,9 @@ jmh { if (project.hasProperty('jmh.profilers')) { profilers = project.property('jmh.profilers').tokenize(',') } + if (project.hasProperty('jmh.jvmArgs')) { + jvmArgsAppend = project.property('jmh.jvmArgs').tokenize(' ') + } if (project.hasProperty('testJvm')) { def testJvmSpec = new TestJvmSpec(project) jvm = testJvmSpec.javaTestLauncher.map { it.executablePath.asFile.absolutePath } 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 4e32410d76c..cea62db6ff8 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -42,6 +42,8 @@ import datadog.trace.api.InstrumenterConfig; import datadog.trace.api.KnownTags; import datadog.trace.api.Pair; +import datadog.trace.api.SizingHint; +import datadog.trace.api.SizingHintTable; import datadog.trace.api.TagMap; import datadog.trace.api.TraceConfig; import datadog.trace.api.civisibility.config.BazelMode; @@ -659,7 +661,8 @@ private CoreTracer( // Dense known-tag store (experimental, OFF by default): registering the KnownTagCodec resolver // flips the dense store live so known tags store without a per-tag Entry. Gated by a system - // property for A/B benchmarking; when off, keyOf stays a no-op and tag storage is byte-identical + // property for A/B benchmarking; when off, keyOf stays a no-op and tag storage is + // byte-identical // to today. Promote to a Config flag if this becomes a permanent rollout. if (Boolean.getBoolean("dd.trace.dense.tags.enabled")) { KnownTags.init(); @@ -2189,6 +2192,14 @@ protected static final DDSpanContext buildSpanContext( requestContextDataIast = builderRequestContextDataIast; } + // Per-type dense-store sizing: an entry (local-root) span carries the trace-metadata / + // enriching tags a child doesn't, so pick the lane by whether we have a local parent. A + // resolved hint sizes the span's TagMap and self-tunes on finish; null (no/unkeyable + // operation + // name) falls back to the generic default capacity. + final boolean entrySpan = !(resolvedParentSpanContext instanceof DDSpanContext); + final SizingHint sizingHint = SizingHintTable.hintFor(operationName, entrySpan); + // some attributes are inherited from the parent context = new DDSpanContext( @@ -2217,7 +2228,8 @@ protected static final DDSpanContext buildSpanContext( tracer.profilingContextIntegration, tracer.injectBaggageAsTags, tracer.injectLinksAsTags, - mergedTracerTagsNeedsIntercept ? null : mergedTracerTags); + mergedTracerTagsNeedsIntercept ? null : mergedTracerTags, + sizingHint); // By setting the tags on the context we apply decorators to any tags that have been set via // the builder. This is the order that the tags were added previously, but maybe the `tags` diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java index a288c405e6f..841a1f7a08f 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java @@ -157,6 +157,7 @@ public boolean isFinished() { private void finishAndAddToTrace(final long durationNano) { // ensure a min duration of 1 if (DURATION_NANO_UPDATER.compareAndSet(this, 0, Math.max(1, durationNano))) { + context.recordDenseSize(); setLongRunningVersion(-this.longRunningVersion); SpanWrapper wrapper = getWrapper(); if (wrapper != null) { 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 a2d87e2c18b..c08becb2a0a 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 @@ -12,6 +12,7 @@ import datadog.trace.api.DDTraceId; import datadog.trace.api.Functions; import datadog.trace.api.ProcessTags; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; import datadog.trace.api.cache.DDCache; import datadog.trace.api.cache.DDCaches; @@ -138,6 +139,13 @@ public class DDSpanContext */ private final TagMap unsafeTags; + // Per-type sizing hint (e.g. a SpanPrototype) this span was created from, if any. Held so the + // span + // can feed its final dense-store size back on finish (recordSize) -- the self-tuning loop that + // lets + // the reused hint converge to the type's real known-tag high-water mark. Null when unsized. + private final SizingHint sizingHint; + /** The service name is required, otherwise the span are dropped by the agent */ private volatile String serviceName; @@ -244,6 +252,7 @@ public DDSpanContext( ProfilingContextIntegration.NoOp.INSTANCE, true, true, + null, null); } @@ -295,6 +304,7 @@ public DDSpanContext( ProfilingContextIntegration.NoOp.INSTANCE, injectBaggageAsTags, injectLinksAsTags, + null, null); } @@ -351,6 +361,7 @@ public DDSpanContext( profilingContextIntegration, injectBaggageAsTags, injectLinksAsTags, + null, null); } @@ -380,7 +391,8 @@ public DDSpanContext( final ProfilingContextIntegration profilingContextIntegration, final boolean injectBaggageAsTags, final boolean injectLinksAsTags, - final TagMap readThroughParent) { + final TagMap readThroughParent, + final SizingHint sizingHint) { assert traceCollector != null; this.traceCollector = traceCollector; @@ -412,7 +424,8 @@ public DDSpanContext( this.unsafeTags = readThroughParent != null ? TagMap.createFromParent(readThroughParent) - : TagMap.create(capacity); + : sizingHint != null ? TagMap.create(sizingHint) : TagMap.create(capacity); + this.sizingHint = sizingHint; // must set this before setting the service and resource names below this.profilingContextIntegration = profilingContextIntegration; @@ -963,6 +976,22 @@ public void setTag(final String tag, final String value) { } } + /** + * Feeds this span's final dense-store size back into the sizing hint it was created from (if + * any). Called once at span finish; lets a reused hint (e.g. a per-type SpanPrototype) self-tune + * to the type's observed known-tag high-water mark, so subsequent spans of the type are sized + * correctly. + */ + void recordDenseSize() { + if (sizingHint != null) { + // Intentionally unsynchronized: recordSize is benign-racy by design (monotonic-max on a plain + // int), and a stale knownCount read only ever costs one extra growth on a later span. The + // lock + // would add nothing but contention on the finish path. + unsafeTags.recordSize(sizingHint); + } + } + public void setTag(TagMap.EntryReader entry) { if (entry == null) { return; diff --git a/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java index 6c11f8bc212..6ada9e787c9 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java @@ -90,6 +90,9 @@ public class DenseStoreAllocBenchmark { private String[] keys; private String[] values; + // Per-type sizing hint seeded to this scenario's known-tag count -- what a SpanPrototype + // supplies. + private SizingHint prototype; @Setup(Level.Trial) public void setup() { @@ -108,8 +111,11 @@ public void setup() { this.keys[i] = i < knownCount ? KNOWN[i] : "custom.tag." + i; this.values[i] = "value-" + i; } + // Size the dense store to the known-tag count (the "end state" per-type sizing). + this.prototype = new SizingHint("bench", 0, Math.max(knownCount, 1)); } + /** Current: generic default dense capacity (KNOWN_INIT_CAP=12) -- over-provisions small types. */ @Benchmark public TagMap buildMap() { TagMap m = TagMap.create(16); @@ -119,6 +125,18 @@ public TagMap buildMap() { return m; } + /** + * End state: dense store sized per-type via a SpanPrototype (SizingHint) -- no over-provision. + */ + @Benchmark + public TagMap buildMapSized() { + TagMap m = TagMap.create(prototype); + 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); diff --git a/internal-api/src/jmh/java/datadog/trace/util/FlatHashtableBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/FlatHashtableBenchmark.java new file mode 100644 index 00000000000..91052ee6a4b --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/FlatHashtableBenchmark.java @@ -0,0 +1,97 @@ +package datadog.trace.util; + +import java.util.HashMap; +import java.util.Map; +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.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Directional: is the {@link FlatHashtable} hit-path lookup (what a span pays per create, all hits + * after warmup) cheap vs a {@link HashMap}? Concrete-typed {@code static final} helper so the + * static-poly specialization is in play. One op = one pass over the op-name set. Single-threaded, + * short — a rough signal, not a verdict (real numbers on the box). + */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 3, time = 1) +@Fork(1) +@Threads(1) +public class FlatHashtableBenchmark { + + static final class StrHelper extends FlatHashtable.StringHelper { + @Override + public boolean matches(String key, String value) { + return key == value || key.equals(value); + } + + @Override + public String create(String key) { + return key; // store the key itself as the (self-identifying) entry + } + } + + private static final StrHelper HELPER = new StrHelper(); + + private static final String[] KEYS = { + "servlet.request", + "database.query", + "http.request", + "grpc.client", + "kafka.produce", + "kafka.consume", + "jdbc.query", + "spring.handler", + "servlet.forward", + "okhttp.request" + }; + + private String[] table; + private Map map; + + @Setup + public void setup() { + table = FlatHashtable.create(String.class, KEYS.length); + map = new HashMap<>(KEYS.length * 2); + for (String k : KEYS) { + FlatHashtable.getOrCreate(table, k, HELPER); + map.put(k, k); + } + } + + /** FlatHashtable all-hit lookups (concrete helper → specialized). */ + @Benchmark + public void flatGet(Blackhole bh) { + for (String k : KEYS) { + bh.consume(FlatHashtable.get(table, k, HELPER)); + } + } + + /** Steady-state span-create shape: get-then-getOrCreate, all hits. */ + @Benchmark + public void flatGetOrCreate(Blackhole bh) { + for (String k : KEYS) { + bh.consume(FlatHashtable.getOrCreate(table, k, HELPER)); + } + } + + /** Baseline: HashMap lookups over the same keys. */ + @Benchmark + public void hashMapGet(Blackhole bh) { + for (String k : KEYS) { + bh.consume(map.get(k)); + } + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHelper.java b/internal-api/src/main/java/datadog/trace/api/SizingHelper.java new file mode 100644 index 00000000000..282be66079e --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHelper.java @@ -0,0 +1,27 @@ +package datadog.trace.api; + +import datadog.trace.util.FlatHashtable; + +/** + * {@link FlatHashtable} policy for the per-operation {@link SizingHint} table: keys by operation + * name, entries are {@code SizingHint}s carrying that name plus its cached spread hash. Stateless — + * held by {@link SizingHintTable} as a concrete-typed {@code static final} singleton so {@code + * FlatHashtable.get}/{@code getOrCreate} specialize (devirtualize + inline) at the call site. + * + *

Extends {@link FlatHashtable.StringHelper}, which seals the spread {@code hash}; this class + * only supplies {@code matches} and {@code create}. Both use the inherited {@code hash} so the + * cached {@link SizingHint#labelHash} is always the same spread the probe used. + */ +final class SizingHelper extends FlatHashtable.StringHelper { + @Override + public boolean matches(String key, SizingHint value) { + // int gate on the cached hash before equals; op-names are usually interned literals, so `==` is + // the common hit. + return value.labelHash == hash(key) && (key == value.label || key.equals(value.label)); + } + + @Override + public SizingHint create(String key) { + return new SizingHint(key, hash(key), SizingHintTable.SEED_SIZE); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHint.java b/internal-api/src/main/java/datadog/trace/api/SizingHint.java new file mode 100644 index 00000000000..498d79f3433 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHint.java @@ -0,0 +1,49 @@ +package datadog.trace.api; + +/** + * Opaque per-operation dense-store sizing hint, and a self-contained {@link + * datadog.trace.util.FlatHashtable} slot: it carries everything the probe compares ({@link #label} + * + cached {@link #labelHash}) plus the tuned payload ({@link #size}). Holding key, hash, and value + * in ONE object behind ONE array slot is deliberate — entry publication is a single reference + * store, so a reader sees {@code null} or a complete entry (never a torn one), and the {@code + * final} identity fields are visible even under racy publication (JMM final-field guarantee). That + * sidesteps the memory-ordering / visibility problems parallel key/hash/value arrays would create, + * no volatile or atomics. + * + *

Opaque to everything outside {@code datadog.trace.api}: no public members. {@link TagMap} + * reads {@link #size} to size a fresh dense store and writes it back (monotonic-max) at a terminal + * point; {@code SizingHelper} mints and compares by {@link #label}/{@link #labelHash}. Callers only + * ever hold the reference. + * + *

{@link #labelHash} is supplied by the helper (a single spread source — {@code + * FlatHashtable.StringHelper.hash}) so the cached gate always matches the probe hash. + */ +public final class SizingHint { + // Identity: final => safely published under a racy single-reference store. `label` is the + // operation + // name (typically an interned literal, so the `==` fast-path usually hits). `labelHash` is the + // helper's spread hash, cached to gate `equals` with an int compare during probing. + final String label; + final int labelHash; + + // Payload: the tuned dense-store size. Plain racy int, updated monotonic-max — a stale/lost read + // only mis-sizes an array (over/under-provision), never corrupts tag data, so no synchronization. + int size; + + // When true, {@code size} is fixed and recordSize won't grow it. For the shared default / + // overflow + // hint (a HETEROGENEOUS catch-all for operation-less / over-budget spans): self-tuning it via + // monotonic-max would converge to the max across unlike sharers and over-provision the lean ones. + final boolean capped; + + SizingHint(String label, int labelHash, int seedSize) { + this(label, labelHash, seedSize, false); + } + + SizingHint(String label, int labelHash, int seedSize, boolean capped) { + this.label = label; + this.labelHash = labelHash; + this.size = seedSize; + this.capped = capped; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java new file mode 100644 index 00000000000..c01e63fb4dc --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHintTable.java @@ -0,0 +1,98 @@ +package datadog.trace.api; + +import datadog.trace.util.FlatHashtable; + +/** + * Process-wide, self-tuning registry of per-operation {@link SizingHint}s, keyed by operation name. + * Pure static: the tracer resolves a hint here at span build and hands it to the span, which sizes + * its dense {@link TagMap} from it and records the actual size back on finish — the hint converges + * to the operation's real known-tag high-water mark, so later spans of that operation size + * correctly. + * + *

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

Bounded + racy by design. Each lane is a fixed-capacity {@link FlatHashtable} (never + * resized) so memory is bounded even under unbounded/dynamic operation names; once a lane's + * cardinality budget is spent, further operations share a capped default hint. Construction, + * insertion, and the monotonic-max size update are all lock-free and deliberately racy — a lost + * update or a double-mint only mis-sizes an array (over/under-provision) for a span or two, never + * corrupts tag data (see {@link FlatHashtable} and {@link SizingHint} for the rationale). + * + *

Keyed by the operation name's {@code String} form. In practice every operation name we see is + * a {@code String} or a {@link datadog.trace.bootstrap.instrumentation.api.UTF8BytesString}, whose + * {@code toString()} just returns its backing field — so the {@code toString()} here is O(1) and + * allocation-free on the hot path. A {@code null} operation name gets no hint (the span falls back + * to the generic default capacity); a missed hint is benign, never wrong. + */ +public final class SizingHintTable { + private SizingHintTable() {} + + // Seed for a fresh per-operation hint: a floor that self-tunes up via monotonic-max recordSize. + static final int SEED_SIZE = 1; + // Fixed size for the shared over-budget hint: a small lean default. Capped (never grown by + // recordSize) because it's a heterogeneous catch-all -- growing it would over-provision lean + // sharers + // to the max of an unlike cohort. + static final int OVERFLOW_SEED = 8; + // Max distinct operation names per lane that get their own hint before collapsing to the capped + // default. Backing capacity is the next power of two >= 2 * this (load factor <= 0.5). + private static final int CARDINALITY_LIMIT = 512; + + // Concrete-typed static-final singleton => FlatHashtable calls specialize at this call site. + private static final SizingHelper HELPER = new SizingHelper(); + + // Entry (local-root, enriched) lane and non-entry (child) lane, keyed by operation name. + private static final SizingHint[] ENTRY_SLOTS = + FlatHashtable.create(SizingHint.class, CARDINALITY_LIMIT); + private static final SizingHint[] CHILD_SLOTS = + FlatHashtable.create(SizingHint.class, CARDINALITY_LIMIT); + + // Shared capped hint each lane returns once its budget is exhausted. + private static final SizingHint ENTRY_OVERFLOW = + new SizingHint(null, 0, OVERFLOW_SEED, /* capped */ true); + private static final SizingHint CHILD_OVERFLOW = + new SizingHint(null, 0, OVERFLOW_SEED, /* capped */ true); + + // Approximate live counts gating each lane's budget. Plain racy ints -- a few over/under the cap + // under contention is harmless (the cap is a safety bound, not an exact quota). + private static int entrySize; + private static int childSize; + + /** + * The sizing hint for {@code operationName} in the given lane: the existing one, a freshly-minted + * (seeded) one if the lane has budget, or the shared capped default if it's full. Returns {@code + * null} for a {@code null} operation name — the span then uses the generic default capacity + * (operation-less spans aren't reliably similar, so they get no hint). Hits are a single probe; + * the create path is warmup-rare. + */ + public static SizingHint hintFor(CharSequence operationName, boolean entrySpan) { + if (operationName == null) { + return null; + } + final String key = + operationName.toString(); // O(1) for String / UTF8BytesString (see class doc) + final SizingHint[] slots = entrySpan ? ENTRY_SLOTS : CHILD_SLOTS; + final SizingHint overflow = entrySpan ? ENTRY_OVERFLOW : CHILD_OVERFLOW; + + final SizingHint existing = FlatHashtable.get(slots, key, HELPER); + if (existing != null) { + return existing; + } + if ((entrySpan ? entrySize : childSize) >= CARDINALITY_LIMIT) { + return overflow; + } + final SizingHint created = FlatHashtable.getOrCreate(slots, key, HELPER); + if (created == null) { + return overflow; // physically full -- shouldn't happen under the cap, but stay safe + } + if (entrySpan) { + entrySize++; // racy approximate count + } else { + childSize++; + } + return created; + } +} 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 8ff476b4df6..fe76ad3dfcb 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -75,6 +75,14 @@ public static final TagMap create(int size) { return new TagMap(); } + /** Creates a mutable TagMap whose dense store is sized per-type from {@code hint}. */ + public static final TagMap create(SizingHint hint) { + TagMap tagMap = new TagMap(); + int n = hint.size; + tagMap.denseCapHint = n > 0 ? n : KNOWN_INIT_CAP; + return 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 @@ -1084,7 +1092,13 @@ public EntryChange next() { private long knownFieldMask; private static final int KNOWN_INIT_CAP = - 12; // generous per-type max stopgap; exact per-type sizing comes with the tag registry + 12; // generous default when no SizingHint; per-type sizing comes via create(SizingHint) + + // Initial dense-array capacity, set once from a SizingHint at create(SizingHint) (per-type + // sizing). + // Just the size (an int), NOT a hint reference -- the hint stays external (recordSize is + // explicit). + private int denseCapHint = KNOWN_INIT_CAP; /** * Optional frozen parent for read-through. When non-null, reads that miss the local buckets fall @@ -1414,8 +1428,8 @@ private int knownIndexOf(long tagId) { private void ensureKnownCapacity() { if (this.knownIds == null) { - this.knownIds = new long[KNOWN_INIT_CAP]; - this.knownValues = new Object[KNOWN_INIT_CAP]; + this.knownIds = new long[this.denseCapHint]; + this.knownValues = new Object[this.denseCapHint]; } else if (this.knownCount == this.knownIds.length) { int newCap = this.knownIds.length << 1; this.knownIds = Arrays.copyOf(this.knownIds, newCap); @@ -1423,6 +1437,18 @@ private void ensureKnownCapacity() { } } + /** + * Feeds this map's final dense-entry count back to {@code hint} (call at a terminal point: freeze + * / span-finish). The hint self-tunes so future maps of the same type size correctly. + */ + public void recordSize(SizingHint hint) { + // Capped hints (the heterogeneous shared default) are fixed -- don't let unlike sharers grow + // them. + if (!hint.capped && this.knownCount > hint.size) { + hint.size = this.knownCount; // monotonic-max; benign racy plain-int write + } + } + /** * Tier-1 presence bit for {@code tagId}: {@code 1L << group-decl} (group-decl is 6 bits ≤ 63). */ diff --git a/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java new file mode 100644 index 00000000000..b5254271779 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/FlatHashtable.java @@ -0,0 +1,152 @@ +package datadog.trace.util; + +import java.lang.reflect.Array; + +/** + * Open-addressed, single-array find-or-create over self-contained entries — each slot is one + * reference to an entry that carries its own key (and, typically, a cached hash). One array, one + * reference per slot: entry publication is a single reference store, so a reader sees {@code null} + * or a complete entry (never a torn one), and {@code final} identity fields on the entry are + * visible under racy publication. That sidesteps the memory-ordering / visibility problems parallel + * key/hash/value arrays would create — no {@code volatile}, no atomics — as long as the payload is + * one where a stale/lost read is benign (miss → recreate; clobber → one wins). + * + *

Static polymorphism (C++-template-style). The per-use policy is a {@link Helper} — a + * stateless subclass held by each caller as a {@code static final} field declared with + * the concrete helper type (not the {@code Helper} base): + * + *

{@code
+ * private static final MyHelper HELPER = new MyHelper();  // concrete type => exact type pinned
+ * ...
+ * V v = FlatHashtable.getOrCreate(table, key, HELPER);
+ * }
+ * + * Because {@code HELPER} is a compile-time-constant of an exact type at the call site, once these + * small {@code Support} methods inline the JIT devirtualizes and inlines {@code hash}/{@code + * matches}/{@code create} — each call site specializes to straight-line code, one instantiation per + * helper, with no CHA/type-profiling dependence. Keep the methods small so they inline; verify with + * {@code -XX:+PrintInlining} (the failure mode is silent: it compiles and runs, just stays + * megamorphic and slow). {@code Helper} is an abstract class, so a distinct final subclass is + * required anyway — an exact type gives the inliner an unambiguous receiver. + * + *

Contract: {@code table.length} must be a power of two ({@link #capacityFor}). {@code + * helper.hash} should be well-distributed (this class masks it directly). Cardinality cap / + * overflow / a live-size counter are caller policy (this class is pure mechanism): a capped + * caller does {@link #get} first, and only on a miss checks its budget before {@link #getOrCreate} + * (so hits stay a single probe and the create path is warmup-rare). + */ +public final class FlatHashtable { + private FlatHashtable() {} + + /** + * Per-use policy. Extend as a stateless final class and hold a {@code static final} + * singleton of the concrete type (see class doc) so the JIT can specialize each call site. + * + *

An abstract class (not an interface) on purpose: it forces a named helper type (no + * lambdas, which can blur the receiver the inliner needs), and if specialization ever misses, the + * fallback dispatches via {@code invokevirtual} rather than the costlier megamorphic {@code + * invokeinterface}. On the specialized (inlined) path the choice is a wash — this just hedges the + * fallback and lets shared bits be {@code final}-sealed later. + * + * @param lookup key + * @param stored entry — self-contained (carries its own key, ideally a cached hash) + */ + public abstract static class Helper { + /** Hash of {@code key}; should be well-distributed (this table masks it directly). */ + public abstract int hash(K key); + + /** Whether the stored {@code value} entry is the one for {@code key}. */ + public abstract boolean matches(K key, V value); + + /** Mint a new entry for {@code key} (called once, on insert). */ + public abstract V create(K key); + } + + /** + * {@link Helper} specialized for {@code String} keys: seals a spread {@link #hash} so String-key + * callers write only {@link #matches} and {@link #create}. Extend as a stateless final class held + * in a concrete-typed {@code static final} singleton, exactly like {@link Helper} — the {@code + * final} hash resolves directly and the concrete subclass still specializes the same at each call + * site, so there's no cost to the extra layer. + * + * @param stored entry — self-contained (carries its own key, ideally a cached hash) + */ + public abstract static class StringHelper extends Helper { + @Override + public final int hash(String key) { + final int h = key.hashCode(); + return h ^ (h >>> 16); // spread; FlatHashtable masks this directly + } + } + + /** Power-of-two capacity for a cardinality budget: {@code >= 2 * limit} (load factor <= 0.5). */ + public static int capacityFor(int cardinalityLimit) { + if (cardinalityLimit <= 0) { + throw new IllegalArgumentException("cardinalityLimit must be positive: " + cardinalityLimit); + } + return Integer.highestOneBit(cardinalityLimit * 2 - 1) << 1; + } + + /** + * Allocates a correctly-typed table for a cardinality budget ({@link #capacityFor} slots). + * Passing {@code type} makes the array's runtime component type {@code T} rather than {@code + * Object[]} — typed reads, real array-store checks, and a monomorphic element type for the JIT. + * Callers can't {@code new T[]} themselves under erasure; this does the one reflective allocation + * at construction (off any hot path). Note: this {@code create} mints the backing array; {@link + * Helper#create} mints an entry — different types, no ambiguity at the call site. + */ + @SuppressWarnings("unchecked") + public static T[] create(Class type, int cardinalityLimit) { + return (T[]) Array.newInstance(type, capacityFor(cardinalityLimit)); + } + + /** + * Existing entry for {@code key}, or {@code null}. Read-only — never creates. Single probe on a + * hit; walks to the first empty slot (or all the way around) on a miss. + */ + public static V get(V[] table, K key, Helper helper) { + final int mask = table.length - 1; + final int start = helper.hash(key) & mask; + int i = start; + for (; ; ) { + final V e = table[i]; + if (e == null) { + return null; // empty slot terminates the probe (no tombstones) + } + if (helper.matches(key, e)) { + return e; + } + i = (i + 1) & mask; + if (i == start) { + return null; // wrapped ⇒ full, absent + } + } + } + + /** + * Existing entry for {@code key}, or a freshly {@link Helper#create created} + inserted one. + * Returns {@code null} only if the table is full (no empty slot) — the caller supplies its + * overflow default. The insert is a single plain reference store: a concurrent clobber / + * double-create is acceptable only when the payload makes it benign (see class doc). + */ + public static V getOrCreate(V[] table, K key, Helper helper) { + final int mask = table.length - 1; + final int start = helper.hash(key) & mask; + int i = start; + for (; ; ) { + final V e = table[i]; + if (e == null) { + final V created = helper.create(key); + table[i] = created; // single-reference publish; benign clobber (see class doc) + return created; + } + if (helper.matches(key, e)) { + return e; + } + i = (i + 1) & mask; + if (i == start) { + return null; // wrapped ⇒ full + } + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableTest.java new file mode 100644 index 00000000000..59b826a5019 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableTest.java @@ -0,0 +1,105 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import org.junit.jupiter.api.Test; + +/** + * Registry semantics for {@link SizingHintTable}. The table is process-wide static state; each test + * uses distinct operation names so tests don't interfere (the only shared, cumulative state is the + * per-lane cardinality counter, which the overflow test drives past its bound with its own names). + */ +class SizingHintTableTest { + + @Test + void nullOperationNameGetsNoHint() { + assertNull(SizingHintTable.hintFor(null, true)); + assertNull(SizingHintTable.hintFor(null, false)); + } + + @Test + void nonStringCharSequenceIsKeyedByContent() { + // Operation names are commonly UTF8BytesString, not String; a content-equal name must resolve + // to the same hint as its String form (keyed by toString(), which is O(1) for these types). + SizingHint viaString = SizingHintTable.hintFor("registry.utf8", true); + CharSequence utf8 = UTF8BytesString.create("registry.utf8"); + assertNotNull(viaString); + assertSame(viaString, SizingHintTable.hintFor(utf8, true)); + } + + @Test + void freshHintIsSeededAndUncapped() { + SizingHint hint = SizingHintTable.hintFor("registry.fresh", true); + assertNotNull(hint); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + assertFalse(hint.capped); + assertEquals("registry.fresh", hint.label); + } + + @Test + void sameOperationAndLaneReturnsSameInstance() { + SizingHint a = SizingHintTable.hintFor("registry.stable", true); + SizingHint b = SizingHintTable.hintFor("registry.stable", true); + assertSame(a, b); + } + + @Test + void entryAndChildLanesAreIndependent() { + SizingHint entry = SizingHintTable.hintFor("registry.twolane", true); + SizingHint child = SizingHintTable.hintFor("registry.twolane", false); + assertNotNull(entry); + assertNotNull(child); + assertNotSame(entry, child, "each lane holds its own hint for the same operation name"); + // ...and each lane is internally stable. + assertSame(entry, SizingHintTable.hintFor("registry.twolane", true)); + assertSame(child, SizingHintTable.hintFor("registry.twolane", false)); + } + + @Test + void beyondCardinalityBudgetSharesACappedOverflowHint() { + // Push far more distinct names than any lane's budget; a capped shared hint must appear. + SizingHint firstOverflow = null; + for (int i = 0; i < 4096 && firstOverflow == null; i++) { + SizingHint hint = SizingHintTable.hintFor("registry.flood." + i, true); + assertNotNull(hint); + if (hint.capped) { + firstOverflow = hint; + } + } + assertNotNull(firstOverflow, "lane eventually collapses to a capped overflow hint"); + assertEquals(SizingHintTable.OVERFLOW_SEED, firstOverflow.size); + + // Every further over-budget name shares that same capped instance. + SizingHint another = SizingHintTable.hintFor("registry.flood.after", true); + assertTrue(another.capped); + assertSame(firstOverflow, another); + } + + @Test + void sizingHintFeedsAndTunesTheDenseStore() { + KnownTags.init(); // register the real allocation-free resolver so known tags route dense + SizingHint hint = SizingHintTable.hintFor("registry.tuning", true); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + + TagMap map = TagMap.create(hint); + map.set(DDTags.BASE_SERVICE, "svc"); + map.set(datadog.trace.bootstrap.instrumentation.api.Tags.COMPONENT, "comp"); + map.set(datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND, "server"); + + map.recordSize(hint); + assertEquals(3, hint.size, "hint self-tunes up to the observed known-tag high-water mark"); + + // Monotonic-max: a smaller later observation does not shrink the hint. + TagMap smaller = TagMap.create(hint); + smaller.set(DDTags.BASE_SERVICE, "svc"); + smaller.recordSize(hint); + assertEquals(3, hint.size, "recordSize never shrinks the hint"); + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java new file mode 100644 index 00000000000..c02ca085d53 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/FlatHashtableTest.java @@ -0,0 +1,158 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class FlatHashtableTest { + + /** + * Self-contained entry: carries its own key + cached spread hash (the FlatHashtable contract). + */ + static final class Entry { + final String key; + final int hash; + + Entry(String key, int hash) { + this.key = key; + this.hash = hash; + } + } + + /** Counts create() calls so tests can prove getOrCreate mints exactly once per key. */ + static final class CountingHelper extends FlatHashtable.StringHelper { + int creates; + + @Override + public boolean matches(String key, Entry value) { + return this.hash(key) == value.hash && key.equals(value.key); + } + + @Override + public Entry create(String key) { + this.creates++; + return new Entry(key, this.hash(key)); + } + } + + /** A helper whose keys all collide to slot 0, to exercise linear probing + fill-to-full. */ + static final class CollidingHelper extends FlatHashtable.Helper { + @Override + public int hash(String key) { + return 0; + } + + @Override + public boolean matches(String key, Entry value) { + return key.equals(value.key); + } + + @Override + public Entry create(String key) { + return new Entry(key, 0); + } + } + + @Test + void capacityForIsPowerOfTwoAtLeastTwiceLimit() { + for (int limit = 1; limit <= 1024; limit++) { + int cap = FlatHashtable.capacityFor(limit); + assertTrue(cap >= 2 * limit, "cap " + cap + " >= 2*" + limit); + assertEquals(0, cap & (cap - 1), "cap " + cap + " is a power of two"); + } + assertEquals(2, FlatHashtable.capacityFor(1)); + assertEquals(4, FlatHashtable.capacityFor(2)); + assertEquals(8, FlatHashtable.capacityFor(3)); + assertEquals(8, FlatHashtable.capacityFor(4)); + assertEquals(1024, FlatHashtable.capacityFor(512)); + } + + @Test + void capacityForRejectsNonPositive() { + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(0)); + assertThrows(IllegalArgumentException.class, () -> FlatHashtable.capacityFor(-1)); + } + + @Test + void createAllocatesTypedArrayOfCapacity() { + Entry[] table = FlatHashtable.create(Entry.class, 512); + assertEquals(1024, table.length); + assertEquals(Entry.class, table.getClass().getComponentType()); + } + + @Test + void getReturnsNullOnEmptyTable() { + Entry[] table = FlatHashtable.create(Entry.class, 8); + assertNull(FlatHashtable.get(table, "absent", new CountingHelper())); + } + + @Test + void getOrCreateMintsOnceThenReturnsSameInstance() { + Entry[] table = FlatHashtable.create(Entry.class, 8); + CountingHelper helper = new CountingHelper(); + + Entry first = FlatHashtable.getOrCreate(table, "op", helper); + assertNotNull(first); + assertEquals(1, helper.creates); + + Entry again = FlatHashtable.getOrCreate(table, "op", helper); + assertSame(first, again, "second getOrCreate returns the existing entry"); + assertEquals(1, helper.creates, "no re-mint on hit"); + + assertSame(first, FlatHashtable.get(table, "op", helper), "get sees the inserted entry"); + } + + @Test + void storesManyDistinctKeysWithinBudget() { + int limit = 200; + Entry[] table = FlatHashtable.create(Entry.class, limit); + CountingHelper helper = new CountingHelper(); + + Set seen = new HashSet<>(); + for (int i = 0; i < limit; i++) { + Entry e = FlatHashtable.getOrCreate(table, "op-" + i, helper); + assertNotNull(e); + seen.add(e); + } + assertEquals(limit, seen.size()); + assertEquals(limit, helper.creates); + + // All still retrievable (probing across collisions works). + for (int i = 0; i < limit; i++) { + assertNotNull(FlatHashtable.get(table, "op-" + i, helper)); + } + } + + @Test + void getOrCreateReturnsNullWhenPhysicallyFull() { + // capacityFor(1) == 2 slots; all keys collide to slot 0 so 2 fills the table. + Entry[] table = FlatHashtable.create(Entry.class, 1); + assertEquals(2, table.length); + CollidingHelper helper = new CollidingHelper(); + + assertNotNull(FlatHashtable.getOrCreate(table, "a", helper)); + assertNotNull(FlatHashtable.getOrCreate(table, "b", helper)); + // Table is now full; a third distinct key has no empty slot. + assertNull(FlatHashtable.getOrCreate(table, "c", helper)); + // But existing keys are still found via the wrapped probe. + assertNotNull(FlatHashtable.get(table, "a", helper)); + assertNotNull(FlatHashtable.get(table, "b", helper)); + assertNull(FlatHashtable.get(table, "c", helper)); + } + + @Test + void stringHelperHashIsSpreadAndStable() { + CountingHelper helper = new CountingHelper(); + int h = helper.hash("component"); + assertEquals(h, helper.hash("component"), "hash is deterministic"); + int raw = "component".hashCode(); + assertEquals(raw ^ (raw >>> 16), h, "hash is the spread of String.hashCode()"); + } +} From 6d24aeb46597179e3573056c313fb60868d432b6 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 23 Jul 2026 09:03:02 -0400 Subject: [PATCH 2/4] Scrub SpanPrototype references from the SizingHint layer SpanPrototype and SizingHint converge in a later PR; on this branch the hint is a SizingHint keyed by operation name (SizingHintTable), so the illustrative SpanPrototype mentions were misleading. Reword comments and rename the benchmark field to sizingHint. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/core/DDSpanContext.java | 16 +++++++--------- .../trace/api/DenseStoreAllocBenchmark.java | 14 ++++++-------- .../src/main/java/datadog/trace/api/TagMap.java | 8 ++++---- 3 files changed, 17 insertions(+), 21 deletions(-) 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 c08becb2a0a..39cff4cadee 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 @@ -139,11 +139,10 @@ public class DDSpanContext */ private final TagMap unsafeTags; - // Per-type sizing hint (e.g. a SpanPrototype) this span was created from, if any. Held so the - // span - // can feed its final dense-store size back on finish (recordSize) -- the self-tuning loop that - // lets - // the reused hint converge to the type's real known-tag high-water mark. Null when unsized. + // Per-operation sizing hint (from SizingHintTable, keyed by operation name) this span was sized + // from, if any. Held so the span can feed its final dense-store size back on finish (recordSize) + // -- the self-tuning loop that lets the reused hint converge to the operation's real known-tag + // high-water mark. Null when unsized. private final SizingHint sizingHint; /** The service name is required, otherwise the span are dropped by the agent */ @@ -977,10 +976,9 @@ public void setTag(final String tag, final String value) { } /** - * Feeds this span's final dense-store size back into the sizing hint it was created from (if - * any). Called once at span finish; lets a reused hint (e.g. a per-type SpanPrototype) self-tune - * to the type's observed known-tag high-water mark, so subsequent spans of the type are sized - * correctly. + * Feeds this span's final dense-store size back into the sizingHint it was created from (if any). + * Called once at span finish; lets a reused hint self-tune to the operation's observed known-tag + * high-water mark, so subsequent spans of the operation are sized correctly. */ void recordDenseSize() { if (sizingHint != null) { diff --git a/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java b/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java index 6ada9e787c9..709ba1ece19 100644 --- a/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/api/DenseStoreAllocBenchmark.java @@ -90,9 +90,9 @@ public class DenseStoreAllocBenchmark { private String[] keys; private String[] values; - // Per-type sizing hint seeded to this scenario's known-tag count -- what a SpanPrototype + // Per-operation sizing hint seeded to this scenario's known-tag count -- what SizingHintTable // supplies. - private SizingHint prototype; + private SizingHint sizingHint; @Setup(Level.Trial) public void setup() { @@ -111,8 +111,8 @@ public void setup() { this.keys[i] = i < knownCount ? KNOWN[i] : "custom.tag." + i; this.values[i] = "value-" + i; } - // Size the dense store to the known-tag count (the "end state" per-type sizing). - this.prototype = new SizingHint("bench", 0, Math.max(knownCount, 1)); + // Size the dense store to the known-tag count (the "end state" per-operation sizing). + this.sizingHint = new SizingHint("bench", 0, Math.max(knownCount, 1)); } /** Current: generic default dense capacity (KNOWN_INIT_CAP=12) -- over-provisions small types. */ @@ -125,12 +125,10 @@ public TagMap buildMap() { return m; } - /** - * End state: dense store sized per-type via a SpanPrototype (SizingHint) -- no over-provision. - */ + /** End state: dense store sized per-operation via a SizingHint -- no over-provision. */ @Benchmark public TagMap buildMapSized() { - TagMap m = TagMap.create(prototype); + TagMap m = TagMap.create(sizingHint); for (int i = 0; i < tagCount; i++) { m.set(keys[i], values[i]); } 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 fe76ad3dfcb..fc7d8d761d5 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1071,10 +1071,10 @@ public EntryChange next() { *

    *
  • Tier 1 — group mask: one bit per declaration group ({@code 1L << group-decl}). A * clear group bit proves EVERY tag of that group is absent, so seeding a fresh group (e.g. - * a {@code SpanPrototype} bulk insert) skips the scan for the whole group — the bulk-insert - * payoff. Disjoint groups across two maps ({@code (a.knownGroupMask & b.knownGroupMask) == - * 0}) prove nothing shadows across them, which the read-through shadow check exploits (see - * {@link #parentDenseVisible}). + * a bulk insert) skips the scan for the whole group — the bulk-insert payoff. Disjoint + * groups across two maps ({@code (a.knownGroupMask & b.knownGroupMask) == 0}) prove nothing + * shadows across them, which the read-through shadow check exploits (see {@link + * #parentDenseVisible}). *
  • Tier 2 — field bloom: when the group bit clashes, fall back to one shared word * over {@code field-decl & 63}. A clear field bit still proves absence; a clash falls * through to the authoritative scan. From 3273f9eff3b8ca2a2be0b6f0e79345a1f514e349 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 23 Jul 2026 09:05:11 -0400 Subject: [PATCH 3/4] Mark the test-only DDSpanContext back-compat ctor @VisibleForTesting Signals intent: this delegating overload has no production caller (CoreTracer uses the full ctor); its ~20 callers are tests/jmh. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/datadog/trace/core/DDSpanContext.java | 2 ++ 1 file changed, 2 insertions(+) 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 39cff4cadee..a14a36902fe 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 @@ -22,6 +22,7 @@ import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.api.internal.TraceSegment; +import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.api.sampling.PrioritySampling; import datadog.trace.api.sampling.SamplingMechanism; import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; @@ -308,6 +309,7 @@ public DDSpanContext( } /** Back-compat ctor (no read-through parent); delegates with a null parent. */ + @VisibleForTesting public DDSpanContext( final DDTraceId traceId, final long spanId, From 956f2c9a2c0c39f18d1eca13f1bd971becb72bab Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 23 Jul 2026 09:07:53 -0400 Subject: [PATCH 4/4] Deprecate + @VisibleForTesting all three test-only DDSpanContext shims The three delegating overloads exist only to spare tests the param churn from signature changes; none has a production caller. Mark them all @Deprecated (steer new code to the full ctor) and @VisibleForTesting (signal intent). Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/datadog/trace/core/DDSpanContext.java | 5 +++++ 1 file changed, 5 insertions(+) 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 a14a36902fe..e2798eb8e31 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 @@ -206,6 +206,8 @@ public class DDSpanContext */ private volatile Map metaStruct = EMPTY_META_STRUCT; + @Deprecated + @VisibleForTesting public DDSpanContext( final DDTraceId traceId, final long spanId, @@ -256,6 +258,8 @@ public DDSpanContext( null); } + @Deprecated + @VisibleForTesting public DDSpanContext( final DDTraceId traceId, final long spanId, @@ -309,6 +313,7 @@ public DDSpanContext( } /** Back-compat ctor (no read-through parent); delegates with a null parent. */ + @Deprecated @VisibleForTesting public DDSpanContext( final DDTraceId traceId,