diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java index c2bcd4a1cfb..eba7c661aff 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SpanCreationBenchmark.java @@ -255,6 +255,40 @@ public void webServerSpanViaPrototype() { span.finish(); } + /** + * Web-server-shaped span the way decorators build it today: started by name, then the decorator's + * prototype stamped over it after start ({@code BaseDecorator.afterStart} -> {@code + * applyOverwriting}). The baseline the construction-time prototype paths have to beat. + */ + @Benchmark + public void webServerSpanRetrofit() { + AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME); + span.applyOverwriting(webProto); + span.setTag(Tags.HTTP_METHOD, HTTP_METHOD_VALUE); + span.setTag(Tags.HTTP_ROUTE, HTTP_ROUTE_VALUE); + span.setTag(Tags.HTTP_URL, HTTP_URL_VALUE); + span.setTag(Tags.HTTP_STATUS, HTTP_STATUS_VALUE); + span.setTag(Tags.PEER_PORT, PEER_PORT_VALUE); + span.finish(); + } + + /** + * JDBC-client-shaped span the way decorators build it today: started by name, then the prototype + * stamped over it after start; see {@link #webServerSpanRetrofit}. + */ + @Benchmark + public void jdbcClientSpanRetrofit() { + AgentSpan span = tracer.startSpan(INSTRUMENTATION_NAME, JDBC_OPERATION_NAME); + span.applyOverwriting(jdbcProto); + span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE); + span.setTag(Tags.DB_USER, DB_USER_VALUE); + span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE); + span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE); + span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE); + span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE); + span.finish(); + } + /** * JDBC/DB-client-shaped span via {@link SpanPrototype}: component, span.kind, and db.type ride * the prototype; the dynamic db.* / peer.* tags are set per-span. Compare against {@link @@ -272,6 +306,23 @@ public void jdbcClientSpanViaPrototype() { span.finish(); } + /** + * JDBC-client-shaped span via {@code singleSpanBuilder(SpanPrototype, ...)}: the thread's + * reusable builder carries the prototype. Compare against {@link #jdbcClientSpanViaPrototype} (a + * fresh multi-span builder) and {@link #jdbcClientSpanViaPrototypeStartSpan} (no builder). + */ + @Benchmark + public void jdbcClientSpanViaPrototypeSingleSpanBuilder() { + AgentSpan span = tracer.singleSpanBuilder(jdbcProto, null).start(); + span.setTag(Tags.DB_INSTANCE, DB_INSTANCE_VALUE); + span.setTag(Tags.DB_USER, DB_USER_VALUE); + span.setTag(Tags.DB_OPERATION, DB_OPERATION_VALUE); + span.setTag(Tags.DB_STATEMENT, DB_STATEMENT_VALUE); + span.setTag(Tags.PEER_HOSTNAME, DB_PEER_HOSTNAME_VALUE); + span.setTag(Tags.PEER_PORT, DB_PEER_PORT_VALUE); + span.finish(); + } + /** * Web-server-shaped span via {@code startSpan(SpanPrototype, ...)} — the builder-free * construction entry (no MultiSpanBuilder allocation), the auto-instrumentation path. Compare diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java index 5556fa26139..f346d02bf2c 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java @@ -8,6 +8,8 @@ import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import datadog.trace.common.writer.Writer; +import java.util.List; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Fork; @@ -44,7 +46,8 @@ * outside {@code Integer}'s box cache, so primitive boxing shows up. * *

This copy: 1.68 snapshot (decorators apply a prototype in {@code afterStart}, #12037; peer - * tags by id, #12715). + * tags by id, #12715), with the dense store's per-operation sizing hints, which the writer feeds as + * serialization would. */ @State(Scope.Benchmark) @Warmup(iterations = 5) @@ -103,7 +106,7 @@ public class TraceShapeBenchmark { @Setup public void setup(Blackhole blackhole) { - this.tracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build(); + this.tracer = CoreTracer.builder().writer(new SizeRecordingWriter(blackhole)).build(); // the decorators' prototype chains: Base (type, component) -> Server / Client (kind, language) this.serverProto = SpanPrototype.builder() @@ -267,6 +270,41 @@ public void newApi() { server.finish(); } + /** + * {@link DropWriter}, except that it first records each span's dense-store size, as serialization + * does for a published trace. Without it the per-operation sizing hints never learn here, and + * every span would be presized to the initial hint and then grow. + */ + static final class SizeRecordingWriter implements Writer { + private final Blackhole blackhole; + + SizeRecordingWriter(Blackhole blackhole) { + this.blackhole = blackhole; + } + + @Override + public void write(List trace) { + for (DDSpan span : trace) { + span.spanContext().recordDenseSize(); + } + blackhole.consume(trace); + } + + @Override + public void start() {} + + @Override + public boolean flush() { + return true; + } + + @Override + public void close() {} + + @Override + public void incrementDropCounts(int spanCount) {} + } + /** The advice's exit: activate around the call, then close and finish. */ void finishScoped(AgentSpan span) { ContextScope scope = tracer.activateSpan(span); 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 74b7188402d..c63a172e3c4 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -42,6 +42,7 @@ import datadog.trace.api.IdGenerationStrategy; import datadog.trace.api.InstrumenterConfig; import datadog.trace.api.Pair; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; import datadog.trace.api.TraceConfig; import datadog.trace.api.civisibility.config.BazelMode; @@ -1160,8 +1161,26 @@ public CoreSpanBuilder buildSpan( if (operationName == null) { operationName = prototype.operationName(); } - CoreSpanBuilder builder = - createMultiSpanBuilder(prototype.instrumentationName(), operationName); + return seedFrom( + createMultiSpanBuilder(prototype.instrumentationName(), operationName), prototype); + } + + /** + * Like {@link #buildSpan(SpanPrototype, CharSequence)}, for one span only: the thread's reusable + * builder carries the prototype (see {@link #singleSpanBuilder(String, CharSequence)}), which it + * drops when reset for the next span. + */ + @Override + public CoreSpanBuilder singleSpanBuilder( + @Nonnull final SpanPrototype prototype, CharSequence operationName) { + if (operationName == null) { + operationName = prototype.operationName(); + } + return seedFrom(singleSpanBuilder(prototype.instrumentationName(), operationName), prototype); + } + + /** Seeds {@code builder}'s prototype and span type; its tags are seeded when the span starts. */ + private static CoreSpanBuilder seedFrom(CoreSpanBuilder builder, SpanPrototype prototype) { builder.spanPrototype = prototype; if (prototype.spanType() != null) { builder.spanType = prototype.spanType(); @@ -1442,8 +1461,9 @@ void write(final SpanList trace) { writtenTrace.get(i).setTag(APM_ENABLED, 0); } } - writer.write(writtenTrace); + writer.write(writtenTrace); // serialization records these spans' sizes } else { + recordDenseSizes(writtenTrace); // with span streaming this won't work - it needs to be changed // to track an effective sampling rate instead, however, tests // checking that a hard reference on a continuation prevents @@ -1455,6 +1475,19 @@ void write(final SpanList trace) { } } + /** + * Tunes each span's per-operation sizing hint, for a trace that is dropped rather than written: + * published spans are recorded when they're serialized, with every tag present. Done here rather + * than at span finish: the early post-processors and the root's sampling decision add tags after + * a span finishes, and a hint that missed them would leave every later span of the operation + * growing its store. + */ + private static void recordDenseSizes(List trace) { + for (int i = 0; i < trace.size(); i++) { + trace.get(i).spanContext().recordDenseSize(); + } + } + private List interceptCompleteTrace(SpanList originalTrace) { return interceptCompleteTrace(interceptors, originalTrace); } @@ -2373,6 +2406,15 @@ protected static final DDSpanContext buildSpanContext( requestContextDataIast = builderRequestContextDataIast; } + // Per-operation dense-store sizing: an entry (local-root) span carries tags a child doesn't, + // so the lane is chosen by whether there's a local parent. A span using its prototype's own + // operation name gets the hint cached on the prototype; any other span (including NONE's, + // which has no operation name) gets its operation's hint from the table. Null with the dense + // store off -- the flag test folds, so the default path does no lookup. + final SizingHint sizingHint = + spanPrototype.sizingHint( + !(resolvedParentSpanContext instanceof DDSpanContext), operationName); + // some attributes are inherited from the parent context = new DDSpanContext( @@ -2400,7 +2442,8 @@ protected static final DDSpanContext buildSpanContext( tracer.profilingContextIntegration, tracer.injectBaggageAsTags, tracer.injectLinksAsTags, - tagParent); + tagParent, + sizingHint); // By setting the tags on the context we apply decorators to any tags that have been set via // the builder. The `mergedTracerTags` are always applied first (the precedence floor: diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 95d8de1eaee..4391ed816d6 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 @@ -20,6 +20,7 @@ import datadog.trace.api.KnownTagCodec; import datadog.trace.api.KnownTags; import datadog.trace.api.ProcessTags; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; import datadog.trace.api.cache.DDCache; import datadog.trace.api.cache.DDCaches; @@ -58,6 +59,7 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -389,6 +391,10 @@ public DDSpanContext( null); } + /** + * Without a sizing hint: for tests and benchmarks that build contexts directly. The tracer uses + * the constructor that takes one. + */ public DDSpanContext( final DDTraceId traceId, final long spanId, @@ -415,6 +421,62 @@ public DDSpanContext( final boolean injectBaggageAsTags, final boolean injectLinksAsTags, final TagMap readThroughParent) { + this( + traceId, + spanId, + parentId, + parentServiceName, + serviceNameSource, + serviceName, + operationName, + resourceName, + samplingPriority, + origin, + baggageItems, + w3cBaggage, + errorFlag, + spanType, + traceCollector, + requestContextDataAppSec, + requestContextDataIast, + CiVisibilityContextData, + pathwayContext, + disableSamplingMechanismValidation, + propagationTags, + profilingContextIntegration, + injectBaggageAsTags, + injectLinksAsTags, + readThroughParent, + null); + } + + public DDSpanContext( + final DDTraceId traceId, + final long spanId, + final long parentId, + final CharSequence parentServiceName, + final CharSequence serviceNameSource, + final String serviceName, + final CharSequence operationName, + final CharSequence resourceName, + final int samplingPriority, + final CharSequence origin, + final Map baggageItems, + final Baggage w3cBaggage, + final boolean errorFlag, + final CharSequence spanType, + final TraceCollector traceCollector, + final Object requestContextDataAppSec, + final Object requestContextDataIast, + final Object CiVisibilityContextData, + final PathwayContext pathwayContext, + final boolean disableSamplingMechanismValidation, + final PropagationTags propagationTags, + final ProfilingContextIntegration profilingContextIntegration, + final boolean injectBaggageAsTags, + final boolean injectLinksAsTags, + final TagMap readThroughParent, + @Nullable final SizingHint sizingHint) { assert traceCollector != null; this.traceCollector = traceCollector; @@ -442,7 +504,7 @@ public DDSpanContext( // Reads through to the frozen tracer-level tags, if any. TagMap parentTags = TagMap.readThroughParent(readThroughParent); - this.knownTags = IdStore.create(parentTags); + this.knownTags = IdStore.create(parentTags, sizingHint); this.customTags = HashStore.inheritedFrom(parentTags); this.customTagsBorrowed = this.customTags != null; @@ -642,11 +704,8 @@ public void setSpanType(final CharSequence spanType) { * *

This is the construction-time seam ({@code CoreSpanBuilder}) -- see {@link * #applyOverwriting} for the decorator {@code afterStart} seam (via {@link - * DDSpan#applyOverwriting}), which needs different precedence. The context owns the tag map, so - * the eventual cheaper bulk-share path (skipping interception for non-intercepted tags) and the - * identity short-circuit will land here, with the dense store; a tag's intercept status is - * already on its id ({@link KnownTagCodec#isIntercepted}). Until then each constant tag is - * prechecked by the interceptor, identical to the per-tag calls this replaces. + * DDSpan#applyOverwriting}), which needs different precedence. The tags are seeded by {@link + * #seedPrototypeTags}, in bulk where the store and the prototype allow it. */ public void apply(@Nonnull final SpanPrototype prototype) { if (this.spanType == null) { @@ -655,7 +714,7 @@ public void apply(@Nonnull final SpanPrototype prototype) { setSpanType(spanType); } } - seedAbsentTags(prototype.tags()); + seedPrototypeTags(prototype); if (this.integrationName == null) { final CharSequence integrationName = prototype.integrationName(); if (integrationName != null) { @@ -676,7 +735,9 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) { setSpanType(spanType); } final TagMap tags = prototype.tags(); - setAllTags(tags, true); + if (!overwriteInBulk(prototype)) { + setAllTags(tags, true); + } tagInterceptor.resolveSplitByTagsPrecedence(this, tags); final CharSequence integrationName = prototype.integrationName(); if (integrationName != null) { @@ -685,25 +746,131 @@ public void applyOverwriting(@Nonnull final SpanPrototype prototype) { } /** - * Seeds tags that are not already present, routed through the interceptor. Mirrors {@link - * #setAllTags(TagMap, boolean)}'s intercepting path but skips any key already set, so explicit - * tags keep precedence over the prototype's constant defaults. + * How a {@link SpanPrototype}'s tags are seeded into a span, cached on the prototype for the + * interceptor it was checked against ({@link SpanPrototype#seedPlan}). */ - private void seedAbsentTags(final TagMap map) { - if (map == null) { - return; + private static final class SeedPlan { + final TagInterceptor interceptor; + + /** + * The store can seed in bulk (the dense store) and no known tag needs the interceptor beyond + * span.kind (TagInterceptor#canSeedInBulk). False with the dense store off, so the default path + * never tries. + */ + final boolean bulk; + + /** The ordinal of the prototype's span.kind, or SPAN_KIND_UNSET. */ + final byte kindOrdinal; + + SeedPlan(TagInterceptor interceptor, boolean bulk, byte kindOrdinal) { + this.interceptor = interceptor; + this.bulk = bulk; + this.kindOrdinal = kindOrdinal; + } + } + + private SeedPlan seedPlanFor(final SpanPrototype prototype) { + Object cached = prototype.seedPlan(); + if (cached instanceof SeedPlan && ((SeedPlan) cached).interceptor == tagInterceptor) { + return (SeedPlan) cached; } + TagMap tags = prototype.tags(); + TagMap.Entry kind = tags.getEntry(KnownTags.SPAN_KIND_ID); + SeedPlan plan = + new SeedPlan( + tagInterceptor, + tags.knownStore().supportsBulkSeed() && tagInterceptor.canSeedInBulk(tags), + kind == null ? SPAN_KIND_UNSET : spanKindOrdinalOf(kind.stringValue())); + prototype.seedPlan(plan); + return plan; + } + + /** + * Seeds a prototype's tags where the span doesn't already have them, so explicit tags keep + * precedence over the prototype's constant defaults. Two paths: + * + *

+ * + * Custom tags always go tag by tag. + */ + private void seedPrototypeTags(final SpanPrototype prototype) { + final TagMap tags = prototype.tags(); + final SeedPlan plan = seedPlanFor(prototype); synchronized (knownTags) { - map.forEach( - this, - (ctx, tagEntry) -> { - if (ctx.isPresent(tagEntry)) { - return; - } - if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.routePut(tagEntry); - } - }); + if (seedKnownInBulk(plan, tags)) { + HashStore custom = tags.customStore(); + if (custom != null) { + // rare: a prototype with custom tags seeds those one at a time + custom.forEach(this, (ctx, tagEntry) -> ctx.seedIfAbsent(tagEntry)); + } + return; + } + tags.forEach(this, (ctx, tagEntry) -> ctx.seedIfAbsent(tagEntry)); + } + } + + /** + * The bulk step shared by {@link #seedPrototypeTags} and {@link #overwriteInBulk}: when the plan + * allows it and the masks prove none of the prototype's known tags can already be on the span, + * copies them in at once ({@link IdStore#seedAbsent}) and sets span.kind's ordinal directly, and + * returns true. Otherwise changes nothing and returns false. Call under the {@code knownTags} + * lock; custom tags are the caller's. + */ + private boolean seedKnownInBulk(final SeedPlan plan, final TagMap tags) { + if (!plan.bulk || !knownTags.seedAbsent(tags.knownStore())) { + return false; + } + if (plan.kindOrdinal != SPAN_KIND_UNSET) { + spanKindOrdinal = plan.kindOrdinal; + } + return true; + } + + /** + * The overwriting counterpart of {@link #seedPrototypeTags}'s bulk path: when the masks prove + * none of the prototype's known tags is on the span, overwriting them is just appending them, so + * they are copied in at once ({@link IdStore#seedAbsent}) and span.kind's ordinal set directly. + * Custom tags go through the interceptor one at a time. Returns false, having changed nothing, + * when the prototype needs the interceptor or a tag may already be present; the caller then + * overwrites tag by tag. + */ + private boolean overwriteInBulk(final SpanPrototype prototype) { + final SeedPlan plan = seedPlanFor(prototype); + if (!plan.bulk) { + return false; // checked before taking the lock: the caller locks again on its fallback + } + final TagMap tags = prototype.tags(); + synchronized (knownTags) { + if (!seedKnownInBulk(plan, tags)) { + return false; + } + HashStore custom = tags.customStore(); + if (custom != null) { + // rare: a prototype with custom tags overwrites those one at a time + custom.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); + } + return true; + } + } + + /** Seeds one tag if the span doesn't have it yet. Call under the {@code knownTags} lock. */ + private void seedIfAbsent(final TagMap.EntryReader tagEntry) { + if (!isPresent(tagEntry)) { + putIntercepted(tagEntry); + } + } + + /** Puts one tag, unless the interceptor routes it. Call under the {@code knownTags} lock. */ + private void putIntercepted(final TagMap.EntryReader tagEntry) { + if (!tagInterceptor.interceptTag(this, tagEntry)) { + routePut(tagEntry); } } @@ -1515,13 +1682,7 @@ void setAllTags(final TagMap map, boolean needsIntercept) { // forEach out-performs the iterator of TagMap // Taking advantage of ability to pass through other context arguments // to avoid using a capturing lambda - map.forEach( - this, - (ctx, tagEntry) -> { - if (!ctx.tagInterceptor.interceptTag(ctx, tagEntry)) { - ctx.routePut(tagEntry); - } - }); + map.forEach(this, (ctx, tagEntry) -> ctx.putIntercepted(tagEntry)); } else { routePutAll(map); } @@ -1683,6 +1844,15 @@ public void setMetaStruct(final String field, final T value) { } } + /** + * Feeds this span's known-tag count back into its operation's {@link SizingHint}, held by its + * dense store, so later spans of the operation are presized. A best-effort racy max; with the + * dense store off this does nothing. + */ + void recordDenseSize() { + knownTags.recordSize(); + } + void earlyProcessTags(AppendableSpanLinks links) { synchronized (knownTags) { TagMap view = tagView(); @@ -1740,6 +1910,11 @@ void processTagsAndBaggage( } } + // Tune the operation's sizing hint now that every tag, serialization-time ones included, is + // present. This is where published spans are recorded; CoreTracer.write records only the + // spans it drops. + recordDenseSize(); + // Baggage Map baggageItemsWithPropagationTags; if (this.injectBaggageAsTags) { diff --git a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java index b487d4665b7..dbff87375d3 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/taginterceptor/TagInterceptor.java @@ -151,6 +151,25 @@ public boolean needsIntercept(TagMap.EntryReader entry) { return tagId != 0 ? needsIntercept(tagId) : isSplitServiceTag(entry.tag()); } + /** + * Whether a span prototype's tags can be seeded in bulk: none of its known tags needs the + * interceptor except {@code span.kind}, whose only effect is the span's cached kind ordinal, + * which the seed sets directly. Custom tags don't count: they are seeded one at a time. Depends + * only on this interceptor's configuration, so a prototype computes it once per interceptor. + */ + public boolean canSeedInBulk(TagMap tags) { + for (TagMap.EntryReader entry : tags) { + long tagId = entry.tagId(); + if (tagId == 0) { + continue; + } + if (tagId == KnownTags.SPAN_KIND_ID ? isSplitServiceTag(tagId) : needsIntercept(tagId)) { + return false; + } + } + return true; + } + /** Whether a custom tag (one with no id) may be routed; only split-by-tags routes them. */ public boolean needsInterceptCustomTag(String customTag) { return isSplitServiceTag(customTag); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java new file mode 100644 index 00000000000..7d880dda6ea --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionDenseForkedTest.java @@ -0,0 +1,109 @@ +package datadog.trace.core; + +import static datadog.trace.bootstrap.instrumentation.api.Tags.COMPONENT; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_CLIENT; +import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND_SERVER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +import datadog.trace.api.IdStore; +import datadog.trace.api.SizingHint; +import datadog.trace.api.config.TracerConfig; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import datadog.trace.common.writer.ListWriter; +import datadog.trace.test.junit.utils.config.WithConfig; +import org.junit.jupiter.api.Test; + +/** + * {@link SpanPrototypeConstructionTest} with the dense store on, where a prototype's known tags are + * seeded in bulk ({@code DDSpanContext.seedPrototypeTags}); runs in its own JVM because the flag is + * read once. Adds the bulk path's fallback: a span that already holds one of the prototype's tags + * is seeded tag by tag, so its own value wins and the rest still land. + */ +@WithConfig(key = TracerConfig.TRACE_DENSE_TAGS_ENABLED, value = "true") +class SpanPrototypeConstructionDenseForkedTest extends SpanPrototypeConstructionTest { + + @Test + void theDenseStoreIsOn() { + // guards the rest of this class: with the flag missed, every test would quietly run on the + // hash store, where the prototype tests pass too + assertEquals("DenseIdStore", IdStore.create(null).getClass().getSimpleName()); + } + + @Test + void aTagTheSpanAlreadyHasKeepsItsValueAndTheRestAreSeeded() { + CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); + try { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initKind(SPAN_KIND_CLIENT) + .initComponentOnly("prototype-component") + .build(); + DDSpan span = (DDSpan) tracer.buildSpan("test-instr", "op").start(); + try { + span.setTag(COMPONENT, "explicit"); + span.spanContext().apply(prototype); + + assertEquals("explicit", span.getTags().get(COMPONENT)); // present: kept + assertEquals(SPAN_KIND_CLIENT, span.getTags().get(SPAN_KIND)); // absent: seeded + assertEquals(SPAN_KIND_CLIENT, span.getSpanKindString()); // and its ordinal + } finally { + span.finish(); + } + } finally { + tracer.close(); + } + } + + @Test + void aPrototypeCachesTheSizingHintForItsOwnOperationNameOnly() { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("sizing.proto.op") + .build(); + SizingHint own = prototype.sizingHint(true, "sizing.proto.op"); + assertNotNull(own); + assertSame(SizingHint.forOperation("sizing.proto.op", true), own, "keyed like the table"); + assertSame(own, prototype.sizingHint(true, UTF8BytesString.create("sizing.proto.op"))); + + // another operation name gets that name's hint, never the prototype's + SizingHint other = prototype.sizingHint(true, "sizing.proto.other"); + assertSame(SizingHint.forOperation("sizing.proto.other", true), other); + assertNotSame(own, other); + + // the entry and child lanes stay apart + assertNotSame(own, prototype.sizingHint(false, "sizing.proto.op")); + } + + @Test + void aPrototypeSeedsEverySpanBuiltFromIt() { + CoreTracer tracer = tracerBuilder().writer(new ListWriter()).build(); + try { + SpanPrototype prototype = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initKind(SPAN_KIND_SERVER) + .initComponentOnly("prototype-component") + .build(); + for (int i = 0; i < 3; i++) { // the first span builds the cached plan, the rest reuse it + DDSpan span = (DDSpan) tracer.buildSpan(prototype, null).start(); + try { + assertEquals("prototype-component", span.getTags().get(COMPONENT)); + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + } finally { + tracer.close(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java index 7a3722d77cf..a2303e3b44a 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/SpanPrototypeConstructionTest.java @@ -8,6 +8,7 @@ import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; import datadog.trace.common.writer.ListWriter; +import java.lang.reflect.Field; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -88,6 +89,98 @@ void startSpanSeedsPrototype() { } } + @Test + void singleSpanBuilderSeedsPrototype() { + DDSpan span = (DDSpan) tracer.singleSpanBuilder(prototype, null).start(); + try { + assertEquals("proto.op", span.getOperationName().toString()); + assertEquals("web", span.getSpanType()); + assertEquals("test-component", span.getTags().get(COMPONENT)); + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + + @Test + void aReusedSingleSpanBuilderDropsThePrototype() { + // the thread's reusable builder carried a prototype for the first span; reset must clear it + tracer.singleSpanBuilder(prototype, null).start().finish(); + DDSpan plain = (DDSpan) tracer.singleSpanBuilder("other-instr", "plain.op").start(); + try { + assertNull(plain.getTags().get(COMPONENT)); + assertNull(plain.getSpanType()); + } finally { + plain.finish(); + } + } + + @Test + void applyOverwritingAfterStartStampsThePrototype() { + // the decorator path: start by name, then afterStart stamps the prototype over the span + DDSpan span = (DDSpan) tracer.startSpan("test-instr", "decorated.op"); + try { + span.applyOverwriting(prototype); + assertEquals("web", span.getSpanType()); + assertEquals("test-component", span.getTags().get(COMPONENT)); + assertEquals(SPAN_KIND_SERVER, span.getTags().get(SPAN_KIND)); + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + + @Test + void applyOverwritingReplacesATagTheSpanAlreadyHas() { + DDSpan span = (DDSpan) tracer.startSpan("test-instr", "decorated.op"); + try { + span.setTag(COMPONENT, "earlier"); + span.applyOverwriting(prototype); + assertEquals("test-component", span.getTags().get(COMPONENT)); // the prototype wins + assertEquals(SPAN_KIND_SERVER, span.getSpanKindString()); + } finally { + span.finish(); + } + } + + @Test + void theSeedPlanTriesTheBulkPathOnlyWhenTheStoreSupportsIt() throws Exception { + // With the hash store (the default) the plan must never be bulk: applyOverwriting would take + // the tag lock for a bulk attempt that always fails, then again for its fallback. + tracer.buildSpan(prototype, null).start().finish(); + Object plan = prototype.seedPlan(); + Field bulk = plan.getClass().getDeclaredField("bulk"); + bulk.setAccessible(true); + assertEquals(prototype.tags().knownStore().supportsBulkSeed(), bulk.getBoolean(plan)); + } + + @Test + void aPrototypesCustomTagIsSeededAndOverwritten() { + SpanPrototype withCustom = + SpanPrototype.builder() + .initInstrumentationName("test-instr") + .initOperationName("proto.op") + .initKind(SPAN_KIND_SERVER) + .initTag("my.custom.constant", "custom-value") + .build(); + DDSpan built = (DDSpan) tracer.buildSpan(withCustom, null).start(); + try { + assertEquals("custom-value", built.getTags().get("my.custom.constant")); + assertEquals(SPAN_KIND_SERVER, built.getSpanKindString()); + } finally { + built.finish(); + } + + DDSpan decorated = (DDSpan) tracer.startSpan("test-instr", "decorated.op"); + try { + decorated.setTag("my.custom.constant", "earlier"); + decorated.applyOverwriting(withCustom); + assertEquals("custom-value", decorated.getTags().get("my.custom.constant")); + } finally { + decorated.finish(); + } + } + @Test void explicitBuilderTagOverridesPrototypeConstant() { // prototype seeds `component` just before the builder's own tags, so the explicit withTag wins 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 bb0e9803eac..c86955a2749 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -3457,7 +3457,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) this.spanBuilderReuseEnabled = configProvider.getBoolean(GeneralConfig.SPAN_BUILDER_REUSE_ENABLED, true); - this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, false); + this.traceDenseTagsEnabled = configProvider.getBoolean(TRACE_DENSE_TAGS_ENABLED, true); this.tagNameUtf8CacheSize = Math.max(configProvider.getInteger(GeneralConfig.TAG_NAME_UTF8_CACHE_SIZE, 128), 0); this.tagValueUtf8CacheSize = diff --git a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java index 6d2d35f5be6..d5954fa193d 100644 --- a/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/DenseIdStore.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Consumer; +import javax.annotation.Nullable; /** * The dense {@link IdStore}: known tags in two parallel arrays -- each tag's 16-bit serial and its @@ -181,6 +182,13 @@ public EntryReader next() { /** The frozen parent's id store, or null. */ final DenseIdStore parent; + /** + * The per-operation hint this store was presized from, and that {@link #recordSize} tunes, or + * null. Held here rather than on the span so a span with the dense store off carries no field for + * it. + */ + @Nullable private final SizingHint hint; + /** * The reused flyweight that writes ({@code put}, {@code getAndPut}, {@code remove}) answer their * prior value with; built on first use. Only writes use it: a store being written has a single @@ -189,8 +197,90 @@ public EntryReader next() { private DenseReader reader; DenseIdStore(IdStore parent) { + this(parent, null); + } + + /** + * Like {@link #DenseIdStore(IdStore)}, with the arrays allocated up front at {@code hint}'s size. + * A span's store presized this way never grows on its first tag, so {@link #grow} stays off the + * write path. A null hint, or one not yet seeded (a racy reader can see 0), keeps the shared + * empty arrays. + */ + DenseIdStore(IdStore parent, @Nullable SizingHint hint) { super(parent); this.parent = (DenseIdStore) parent; + this.hint = hint; + if (hint != null) { + int cap = hint.size; // read the racy field once + if (cap > 0) { + this.serials = new char[cap]; + this.values = new Object[cap]; + } + } + } + + /** + * The bulk seed: when {@code source} is a parentless dense store with no tombstones and its + * occupancy words share no bit with this store's (live, tombstoned or inherited, per level), none + * of its tags can be present here, so its serials and values are appended with two array copies + * and its occupancy ORed in. The occupancy words over-approximate presence, so a shared bit only + * means "maybe present" and falls back to the caller's tag-by-tag seed. + */ + @Override + public boolean supportsBulkSeed() { + return true; + } + + @Override + public boolean seedAbsent(IdStore source) { + if (!(source instanceof DenseIdStore)) { + return false; + } + DenseIdStore src = (DenseIdStore) source; + int n = src.used; + if (src.parent != null || n != src.count) { + return false; // read-through or tombstones: not a plain list of tags + } + long maybeHere = this.tombstoneMask | this.inheritedMask; + if ((src.spanOccupancy & (this.spanOccupancy | maybeHere)) != 0L + || (src.traceOccupancy & (this.traceOccupancy | maybeHere)) != 0L) { + return false; + } + if (n == 0) { + return true; + } + int used = this.used; + if (used + n > this.serials.length) { + this.growTo(used + n); + } + System.arraycopy(src.serials, 0, this.serials, used, n); + System.arraycopy(src.values, 0, this.values, used, n); + this.used = used + n; + this.count += n; + this.spanOccupancy |= src.spanOccupancy; + this.traceOccupancy |= src.traceOccupancy; + return true; + } + + /** Grows the arrays to hold at least {@code minCapacity} slots. Rare, so kept out of line. */ + private void growTo(int minCapacity) { + int newCap = + Math.max(minCapacity, Math.min(this.serials.length << 1, KnownTagCodec.serialLimit())); + this.serials = Arrays.copyOf(this.serials, newCap); + this.values = Arrays.copyOf(this.values, newCap); + } + + @Override + public void recordSize() { + // Capped hints (the shared overflow) are fixed: unlike sharers mustn't grow them. Best-effort + // racy max; a benign plain-int write. + SizingHint hint = this.hint; + if (hint != null && !hint.capped) { + int used = this.used; + if (used > hint.size) { + hint.size = used; + } + } } /** @@ -305,11 +395,11 @@ public EntryReader getAndPut(EntryReader entryReader) { this.markLocal(bit, level); this.count++; } - this.values[i] = entryReader.objectValue(); } else { prior = this.mayInherit(bit, level) ? this.parent.getVisible(serial, bit, level) : null; - this.append(serial, bit, level, entryReader.objectValue()); + i = this.appendSlot(serial, bit, level); } + this.values[i] = entryReader.objectValue(); // the one reference store; see store return this.readerOf(tagId, prior); } @@ -339,7 +429,8 @@ public EntryReader remove(long tagId) { this.values[i] = TOMBSTONE; this.count--; } else { - this.appendSlot(serial, TOMBSTONE); + int slot = this.reserveSlot(serial); // first: it may replace this.values + this.values[slot] = TOMBSTONE; } this.markTombstone(bit, level); return this.readerOf(tagId, local != null ? local : inherited); @@ -358,35 +449,43 @@ private Object store(long tagId, Object value) { long bit = slotBit(tagId); int level = levelOf(tagId); int i = this.mayHaveLocal(bit, level) ? this.indexOf(serial) : -1; + Object prior; if (i >= 0) { - Object prior = this.values[i]; - this.values[i] = value; + prior = this.values[i]; if (prior == TOMBSTONE) { + prior = null; // a tombstone replaced is a removal undone: no prior local value this.markLocal(bit, level); - this.count++; // a tombstone replaced is a removal undone: no prior local value - return null; + this.count++; } - return prior; + } else { + prior = null; + i = this.appendSlot(serial, bit, level); } - this.append(serial, bit, level, value); - return null; + // The one reference store on this path. Under G1 (before JDK 24's late barrier expansion) C2 + // expands each reference-store site's write barriers inline, roughly 250 bytes apiece, so the + // overwrite and append paths share this store rather than each having their own: that keeps + // the setters that inline this small enough to inline into their callers. + this.values[i] = value; + return prior; } - /** Appends a live local tag. */ - private void append(char serial, long bit, int level, Object value) { - this.appendSlot(serial, value); + /** Reserves a slot for a new live local tag, returning its index; the caller stores the value. */ + private int appendSlot(char serial, long bit, int level) { + int i = this.reserveSlot(serial); this.markLocal(bit, level); this.count++; + return i; } - private void appendSlot(char serial, Object value) { + /** Reserves the next slot for {@code serial}, returning its index; its value is left unset. */ + private int reserveSlot(char serial) { int i = this.used; if (i == this.serials.length) { this.grow(); } this.serials[i] = serial; - this.values[i] = value; this.used = i + 1; + return i; } /** Removes slot {@code i}, swapping the last slot into its place. The masks stay as they were. */ @@ -417,11 +516,9 @@ private void grow() { if (length == 0) { this.serials = new char[INIT_CAP]; this.values = new Object[INIT_CAP]; - return; + } else { + this.growTo(length + 1); // doubles, bounded by the serial limit } - int newCap = Math.max(Math.min(length << 1, KnownTagCodec.serialLimit()), length + 1); - this.serials = Arrays.copyOf(this.serials, newCap); - this.values = Arrays.copyOf(this.values, newCap); } /** The reused flyweight over a write's prior {@code value}, or null when there is none. */ @@ -524,7 +621,7 @@ private boolean readIfVisible(DenseIdStore level, int i, DenseReader reader) { } @Override - void forEach(Consumer consumer) { + public void forEach(Consumer consumer) { DenseReader reader = new DenseReader(); for (DenseIdStore level = this; level != null; level = level.parent) { for (int i = 0; i < level.used; ++i) { @@ -536,7 +633,7 @@ void forEach(Consumer consumer) { } @Override - void forEach(T thisObj, BiConsumer consumer) { + public void forEach(T thisObj, BiConsumer consumer) { DenseReader reader = new DenseReader(); for (DenseIdStore level = this; level != null; level = level.parent) { for (int i = 0; i < level.used; ++i) { @@ -548,7 +645,8 @@ void forEach(T thisObj, BiConsumer consumer) { } @Override - void forEach(T thisObj, U otherObj, TriConsumer consumer) { + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { DenseReader reader = new DenseReader(); for (DenseIdStore level = this; level != null; level = level.parent) { for (int i = 0; i < level.used; ++i) { diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java index 32c065fa8b2..81b5ee5e65f 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -309,7 +309,7 @@ boolean parentEntryVisible(Entry parentEntry, HashIdStore fromAncestor) { } @Override - void forEach(Consumer consumer) { + public void forEach(Consumer consumer) { HashSupport.forEach(this.buckets, consumer); if (this.parent != null) { this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); @@ -317,7 +317,7 @@ void forEach(Consumer consumer) { } @Override - void forEach(T thisObj, BiConsumer consumer) { + public void forEach(T thisObj, BiConsumer consumer) { HashSupport.forEach(this.buckets, thisObj, consumer); if (this.parent != null) { this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry)); @@ -325,7 +325,8 @@ void forEach(T thisObj, BiConsumer consumer) { } @Override - void forEach(T thisObj, U otherObj, TriConsumer consumer) { + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); if (this.parent != null) { this.forEachParent(thisObj, otherObj, consumer); diff --git a/internal-api/src/main/java/datadog/trace/api/HashStore.java b/internal-api/src/main/java/datadog/trace/api/HashStore.java index 8b526d385a0..e142b6e5671 100644 --- a/internal-api/src/main/java/datadog/trace/api/HashStore.java +++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java @@ -263,21 +263,22 @@ boolean parentEntryVisible(Entry parentEntry, HashStore fromAncestor) { } /** Visits the local entries, then the visible inherited ones. */ - void forEach(Consumer consumer) { + public void forEach(Consumer consumer) { HashSupport.forEach(this.buckets, consumer); if (this.parent != null) { this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); } } - void forEach(T thisObj, BiConsumer consumer) { + public void forEach(T thisObj, BiConsumer consumer) { HashSupport.forEach(this.buckets, thisObj, consumer); if (this.parent != null) { this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry)); } } - void forEach(T thisObj, U otherObj, TriConsumer consumer) { + public void forEach( + T thisObj, U otherObj, TriConsumer consumer) { HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); if (this.parent != null) { this.forEachParent(thisObj, otherObj, consumer); diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java index fec64355c51..4de1cef8afe 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStore.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Consumer; +import javax.annotation.Nullable; /** * Known tags keyed by their {@link KnownTags} id. Every method takes an id, or an {@link @@ -79,7 +80,40 @@ interface Cursor { /** A fresh id store reading through to {@code parent}'s known tags (parent may be null). */ public static IdStore create(TagMap parent) { - return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore); + return create(parent, null); + } + + /** + * Like {@link #create(TagMap)}, presized from {@code hint} (see {@link SizingHintTable}); a null + * hint, or a store that isn't sized per span, ignores it. + */ + public static IdStore create(TagMap parent, @Nullable SizingHint hint) { + return IdStoreFactory.Default.INSTANCE.create(parent == null ? null : parent.knownStore, hint); + } + + /** + * Feeds this store's size back into the hint it was presized from -- a best-effort, racy max, so + * later stores of the same operation are presized to it. A store created without a hint, or with + * a capped one, or that isn't sized per span, does nothing. + */ + public void recordSize() {} + + /** + * Copies every tag of {@code source} in at once if none of them can already be present here -- + * locally, as a tombstone, or inherited -- and returns true; otherwise copies nothing and returns + * false, and the caller seeds tag by tag. A store that can't prove absence in bulk always returns + * false. {@code source} must be frozen. + */ + public boolean seedAbsent(IdStore source) { + return false; + } + + /** + * Whether {@link #seedAbsent} can ever succeed for this kind of store, so a caller can skip it, + * and the lock it needs, when it can't. + */ + public boolean supportsBulkSeed() { + return false; } /** {@code tagId}'s slot bit. */ @@ -227,11 +261,11 @@ static IllegalArgumentException notKnown(long tagId) { abstract boolean hasParent(); /** Visits the local entries, then the visible inherited ones. */ - abstract void forEach(Consumer consumer); + public abstract void forEach(Consumer consumer); - abstract void forEach(T thisObj, BiConsumer consumer); + public abstract void forEach(T thisObj, BiConsumer consumer); - abstract void forEach( + public abstract void forEach( T thisObj, U otherObj, TriConsumer consumer); /** diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java index 765906c62ec..98d3d823f64 100644 --- a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -1,5 +1,7 @@ package datadog.trace.api; +import javax.annotation.Nullable; + /** * Builds the {@link IdStore}s of the one implementation this JVM uses: {@link DenseIdStore} when * the dense store is on, {@link HashIdStore} otherwise. Rollout scaffolding: the hash store stays @@ -16,14 +18,23 @@ abstract class IdStoreFactory { /** The JVM's store, chosen once by the dense-store flag. */ static final class Default { + /** Whether the dense store is on; folds wherever it is tested. */ + static final boolean DENSE = Config.get().isTraceDenseTagsEnabled(); + static final IdStoreFactory INSTANCE = - Config.get().isTraceDenseTagsEnabled() - ? DenseIdStoreFactory.INSTANCE - : HashIdStoreFactory.INSTANCE; + DENSE ? DenseIdStoreFactory.INSTANCE : HashIdStoreFactory.INSTANCE; } /** A fresh store reading through to {@code parent} (may be null). */ - abstract IdStore create(IdStore parent); + final IdStore create(IdStore parent) { + return create(parent, null); + } + + /** + * A fresh store reading through to {@code parent} (may be null), presized from {@code hint} where + * the store keeps sized arrays; a null hint presizes nothing. + */ + abstract IdStore create(IdStore parent, @Nullable SizingHint hint); /** A new empty parentless store, for a frozen map. */ abstract IdStore createEmpty(); @@ -35,8 +46,8 @@ final class HashIdStoreFactory extends IdStoreFactory { private HashIdStoreFactory() {} @Override - IdStore create(IdStore parent) { - return new HashIdStore(parent); + IdStore create(IdStore parent, @Nullable SizingHint hint) { + return new HashIdStore(parent); // buckets aren't sized per span; a hint is only for dense } @Override @@ -51,8 +62,8 @@ final class DenseIdStoreFactory extends IdStoreFactory { private DenseIdStoreFactory() {} @Override - IdStore create(IdStore parent) { - return new DenseIdStore(parent); + IdStore create(IdStore parent, @Nullable SizingHint hint) { + return new DenseIdStore(parent, hint); } @Override 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..238c5620178 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/SizingHint.java @@ -0,0 +1,56 @@ +package datadog.trace.api; + +import datadog.trace.util.FlatHashtable; +import javax.annotation.Nullable; + +/** + * Opaque per-operation dense-store sizing hint, and a self-contained {@link FlatHashtable.D1} + * entry: it carries everything the probe compares (the operation name as the {@link + * FlatHashtable.D1.Entry} key, plus the cached hash from {@link FlatHashtable.Entry#hash}) and the + * tuned payload ({@link #size}). Holding key, hash, and value in ONE object behind ONE array slot + * is deliberate — entry publication is a single reference store, so a reader sees {@code null} or a + * complete entry (never a torn one), and the {@code final} identity fields are visible even under + * racy publication (JMM final-field guarantee). That sidesteps the memory-ordering / visibility + * problems parallel key/hash/value arrays would create, no volatile or atomics. + * + *

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

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

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

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

Keyed by the operation name's {@code String} form. In practice every operation name we see is + * a {@code String} or a {@link datadog.trace.bootstrap.instrumentation.api.UTF8BytesString}, whose + * {@code toString()} just returns its backing field — so the {@code toString()} here is O(1) and + * allocation-free on the hot path. A {@code null} operation name gets no hint (the span falls back + * to the generic default capacity); a missed hint is benign, never wrong. + */ +final class SizingHintTable { + private SizingHintTable() {} + + // Seed for a fresh per-operation hint: a small floor that self-tunes up via best-effort-max + // recordSize. Kept small because the hint only grows -- a large seed would lock small spans into + // oversized arrays forever; a span that outgrows it pays one doubling. + static final int SEED_SIZE = 4; + + // Fixed size for the shared over-budget hint: the untuned default, DenseIdStore's initial + // capacity (a compile-time constant, so reading it loads no class). Capped (never grown by + // recordSize) because it's a heterogeneous catch-all -- growing it would over-provision lean + // sharers to the max of an unlike cohort. + static final int OVERFLOW_SEED = DenseIdStore.INIT_CAP; + + // Max distinct operation names per lane that get their own hint before collapsing to the capped + // overflow hint. + static final int CARDINALITY_LIMIT = 512; + + /** + * The lanes, in a holder so they are only allocated once the dense store is on and a span asks + * for a hint. + */ + static final class Lanes { + private Lanes() {} + + // Entry (local-root, enriched) lane and non-entry (child) lane, keyed by operation name. + static final FlatHashtable.D1 ENTRY = + FlatHashtable.D1.createFixed(SizingHint.class, CARDINALITY_LIMIT); + static final FlatHashtable.D1 CHILD = + FlatHashtable.D1.createFixed(SizingHint.class, CARDINALITY_LIMIT); + + // Shared capped hint each lane returns once its budget is exhausted. + static final SizingHint ENTRY_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true); + static final SizingHint CHILD_OVERFLOW = new SizingHint(null, OVERFLOW_SEED, /* capped */ true); + } + + /** + * The sizing hint for {@code operationName} in the given lane: the existing one, a freshly-minted + * (seeded) one if the lane has budget, or the shared capped overflow hint if it's full. Returns + * {@code null} when the dense store is off (constant-folded, so the default span path does no + * lookup) and for a {@code null} operation name — the span then uses the generic default capacity + * (operation-less spans aren't reliably similar, so they get no hint). Hits are a single probe; + * the create path is warmup-rare. + */ + @Nullable + static SizingHint hintFor(@Nullable CharSequence operationName, boolean entrySpan) { + if (!IdStoreFactory.Default.DENSE) { + return null; + } + if (operationName == null) { + return null; + } + // O(1) for String / UTF8BytesString (see class doc) + final String key = operationName.toString(); + final FlatHashtable.D1 lane = entrySpan ? Lanes.ENTRY : Lanes.CHILD; + // One probe: returns a hit directly, and null only when the fixed lane is full and the key is + // absent. + final SizingHint hint = lane.tryGetOrCreateOrNull(key, SizingHint::new); + if (hint != null) { + return hint; + } + return entrySpan ? Lanes.ENTRY_OVERFLOW : Lanes.CHILD_OVERFLOW; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index 37733e11c7e..6b28b431641 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -1309,6 +1309,12 @@ private static Entry entryOf(EntryReader reader) { return reader == null ? null : reader.entry(); } + /** This map's known-tag store. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public IdStore knownStore() { + return this.knownStore; + } + /** This map's custom store: null, borrowed ({@link #isCustomStoreBorrowed}), or its own. */ @Restricted(allowedIn = {"datadog.trace.core"}) public HashStore customStore() { diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java index 71767ea7fae..c0fb2936ad0 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java @@ -360,6 +360,21 @@ default AgentSpan startSpan(@Nonnull SpanPrototype prototype, CharSequence opera */ SpanBuilder singleSpanBuilder(String instrumentationName, CharSequence spanName); + /** + * Returns a SpanBuilder seeded from a {@link SpanPrototype}, like {@link + * #buildSpan(SpanPrototype, CharSequence)}, that produces one and only one span, so it can be + * cheaper (see {@link #singleSpanBuilder(String, CharSequence)}). {@code operationName} + * overrides the prototype's when non-null. + * + *

This default seeds identity only, like {@link #buildSpan(SpanPrototype, CharSequence)}'s. + */ + default SpanBuilder singleSpanBuilder( + @Nonnull SpanPrototype prototype, CharSequence operationName) { + return singleSpanBuilder( + prototype.instrumentationName(), + operationName != null ? operationName : prototype.operationName()); + } + void close(); /** diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java index 24ccd2b39d8..830ca3d33be 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/SpanPrototype.java @@ -1,6 +1,9 @@ package datadog.trace.bootstrap.instrumentation.api; +import datadog.apicontract.Restricted; +import datadog.trace.api.SizingHint; import datadog.trace.api.TagMap; +import javax.annotation.Nullable; /** * A baked-once, frozen descriptor of a span's constant initial state — the per-decorator constants @@ -30,6 +33,23 @@ public static Builder builder() { private final CharSequence integrationName; private final TagMap tags; // frozen + /** + * The tracer core's plan for seeding these tags into a span (whether they can be copied in bulk, + * and the span kind they carry), built on first use. Racy by design: the plan is immutable, so a + * reader sees null or a complete plan, and two threads building it at once just build it twice. + */ + private Object seedPlan; + + /** + * The dense-store sizing hints for spans that use this prototype's own operation name, in the + * entry-span and child-span lanes, resolved on first use so later spans skip the table lookup. + * Racy like {@link #seedPlan}: a hint is stable for its operation name, so two threads resolving + * it at once agree. + */ + private SizingHint entryHint; + + private SizingHint childHint; + private SpanPrototype(final Builder builder) { this.instrumentationName = builder.instrumentationName; this.operationName = builder.operationName; @@ -64,6 +84,51 @@ public TagMap tags() { return tags; } + /** The tracer core's seed plan for these tags, or null before the first span uses it. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public Object seedPlan() { + return seedPlan; + } + + /** Records the tracer core's seed plan for these tags; see {@link #seedPlan()}. */ + @Restricted(allowedIn = {"datadog.trace.core"}) + public void seedPlan(Object plan) { + this.seedPlan = plan; + } + + /** + * The sizing hint for a span built from this prototype with {@code operationName}, in the + * entry-span or child-span lane; null with the dense store off. Hints are keyed by operation + * name, like {@link SizingHint#forOperation}: when the span uses the prototype's own operation + * name, the hint is resolved once per lane and cached here, so the span skips the table lookup; a + * span given a different operation name gets that name's hint from the table, so spans of unlike + * operations never share one. + */ + @Restricted(allowedIn = {"datadog.trace.core"}) + @Nullable + public SizingHint sizingHint(boolean entrySpan, @Nullable CharSequence operationName) { + CharSequence own = this.operationName; + if (own == null || !sameName(own, operationName)) { + return SizingHint.forOperation(operationName, entrySpan); + } + SizingHint hint = entrySpan ? this.entryHint : this.childHint; + if (hint == null) { + hint = SizingHint.forOperation(own, entrySpan); + if (entrySpan) { + this.entryHint = hint; + } else { + this.childHint = hint; + } + } + return hint; + } + + /** Whether {@code name} is {@code own}: the same instance, or the same characters. */ + private static boolean sameName(CharSequence own, @Nullable CharSequence name) { + // toString() is O(1) for the String and UTF8BytesString operation names we see + return own == name || (name != null && own.toString().equals(name.toString())); + } + public static final class Builder { private String instrumentationName; private CharSequence operationName; diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java new file mode 100644 index 00000000000..cd62bad0c2b --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableForkedTest.java @@ -0,0 +1,218 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Registry semantics for {@link SizingHintTable}, and how {@link DenseIdStore} sizes from and tunes + * a {@link SizingHint}. Hints are only minted with the dense store on, and the flag is read once + * into a static final, so this runs in its own JVM ({@code ForkedTest}) with the flag set first. + * + *

The table is process-wide static state; each test uses distinct operation names so tests don't + * interfere. The overflow test fills the child lane, so no other test here uses that lane expecting + * a fresh hint. + */ +class SizingHintTableForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "true"); + } + + /** Distinct known tag ids, in a stable order. */ + static final List KNOWN_IDS = new ArrayList<>(); + + @BeforeAll + static void checkSetup() throws IllegalAccessException { + assertTrue(IdStoreFactory.Default.DENSE, "dense store must be on in this JVM"); + for (Field field : KnownTags.class.getFields()) { + if (field.getType() != long.class) continue; + long id = field.getLong(null); + if (KnownTagCodec.isKnown(id) && !KNOWN_IDS.contains(id)) { + KNOWN_IDS.add(id); + } + } + assertTrue(KNOWN_IDS.size() > 24, "need enough known tags to exercise growth"); + } + + private static IdStore storeFor(SizingHint hint) { + IdStore store = IdStore.create(null, hint); + assertSame(DenseIdStore.class, store.getClass()); + return store; + } + + private static Object field(IdStore store, String name) { + try { + Field f = DenseIdStore.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(store); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private static int capacity(IdStore store) { + char[] serials = (char[]) field(store, "serials"); + Object[] values = (Object[]) field(store, "values"); + assertEquals(serials.length, values.length, "dense arrays stay parallel"); + return serials.length; + } + + /** Sets the first {@code n} known tags. */ + private static void fill(IdStore store, int n) { + for (int i = 0; i < n; ++i) { + store.set(KNOWN_IDS.get(i), "v" + i); + } + assertEquals(n, store.size()); + } + + @Test + void nullOperationNameGetsNoHint() { + assertNull(SizingHintTable.hintFor(null, true)); + assertNull(SizingHintTable.hintFor(null, false)); + } + + @Test + void nonStringCharSequenceIsKeyedByContent() { + // Operation names are commonly UTF8BytesString, not String; a content-equal name must resolve + // to the same hint as its String form (keyed by toString(), which is O(1) for these types). + SizingHint viaString = SizingHintTable.hintFor("registry.utf8", true); + CharSequence utf8 = UTF8BytesString.create("registry.utf8"); + assertNotNull(viaString); + assertSame(viaString, SizingHintTable.hintFor(utf8, true)); + } + + @Test + void freshHintIsSeededAndUncapped() { + SizingHint hint = SizingHintTable.hintFor("registry.fresh", true); + assertNotNull(hint); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + assertFalse(hint.capped); + assertEquals("registry.fresh", hint.key()); + } + + @Test + void sameOperationAndLaneReturnsSameInstance() { + assertSame( + SizingHintTable.hintFor("registry.stable", true), + SizingHintTable.hintFor("registry.stable", true)); + } + + @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"); + assertSame(entry, SizingHintTable.hintFor("registry.twolane", true)); + assertSame(child, SizingHintTable.hintFor("registry.twolane", false)); + } + + @Test + void beyondCardinalityBudgetSharesACappedOverflowHint() { + // A hint minted before the lane fills keeps resolving to itself afterwards. + SizingHint early = SizingHintTable.hintFor("registry.flood.early", false); + assertFalse(early.capped); + + // Fill the child lane past its budget; a capped shared hint must appear. + SizingHint firstOverflow = null; + for (int i = 0; i <= SizingHintTable.CARDINALITY_LIMIT && firstOverflow == null; i++) { + SizingHint hint = SizingHintTable.hintFor("registry.flood." + i, false); + assertNotNull(hint); + if (hint.capped) { + firstOverflow = hint; + } + } + assertNotNull(firstOverflow, "lane collapses to a capped overflow hint once full"); + assertEquals(SizingHintTable.OVERFLOW_SEED, firstOverflow.size); + + // Every further over-budget name shares that same capped instance... + SizingHint another = SizingHintTable.hintFor("registry.flood.after", false); + assertTrue(another.capped); + assertSame(firstOverflow, another); + // ...existing hints still resolve (the cap blocks only creation)... + assertSame(early, SizingHintTable.hintFor("registry.flood.early", false)); + // ...and the entry lane has its own budget. + assertFalse(SizingHintTable.hintFor("registry.flood.after", true).capped); + + // A capped hint is never tuned: a heavy span sharing it does not grow it. + IdStore heavy = storeFor(another); + fill(heavy, SizingHintTable.OVERFLOW_SEED + 4); + heavy.recordSize(); + assertEquals(SizingHintTable.OVERFLOW_SEED, another.size); + } + + @Test + void sizingHintPresizesTheDenseStore() { + SizingHint hint = SizingHintTable.hintFor("registry.presize", true); + assertEquals( + hint.size, capacity(storeFor(hint)), "presized at the hint's size, before a write"); + + // Without a hint, a store starts on the shared empty arrays. + assertEquals(0, capacity(storeFor(null))); + assertEquals(0, capacity(IdStore.create(null))); + } + + @Test + void sizingHintFeedsAndTunesTheDenseStore() { + SizingHint hint = SizingHintTable.hintFor("registry.tuning", true); + assertEquals(SizingHintTable.SEED_SIZE, hint.size); + + int observed = SizingHintTable.SEED_SIZE + 3; + IdStore store = storeFor(hint); + fill(store, observed); + store.recordSize(); + assertEquals(observed, hint.size, "hint self-tunes up to the observed high-water mark"); + + // The next store of the operation is presized to fit, so it never grows. + IdStore next = storeFor(hint); + assertEquals(observed, capacity(next)); + fill(next, observed); + assertEquals(observed, capacity(next)); + + // A smaller later observation does not shrink the hint. + IdStore smaller = storeFor(hint); + fill(smaller, 1); + smaller.recordSize(); + assertEquals(observed, hint.size, "recordSize never shrinks the hint"); + + // A store without a hint records nothing. + storeFor(null).recordSize(); + } + + @Test + void fullArraysDoubleUpToTheSerialLimit() { + // Presized small: 4 -> 8 -> 16 + IdStore store = storeFor(new SizingHint("registry.growth")); + fill(store, 4); + assertEquals(4, capacity(store)); + fill(store, 5); + assertEquals(8, capacity(store)); + fill(store, 9); + assertEquals(16, capacity(store)); + + // Unhinted: first allocation at INIT_CAP, then doubling + IdStore unhinted = storeFor(null); + fill(unhinted, 1); + assertEquals(DenseIdStore.INIT_CAP, capacity(unhinted)); + fill(unhinted, DenseIdStore.INIT_CAP + 1); + assertEquals(2 * DenseIdStore.INIT_CAP, capacity(unhinted)); + + // Growth never passes the serial limit, yet always makes room. + IdStore all = storeFor(new SizingHint("registry.growth.all")); + fill(all, KNOWN_IDS.size()); + assertTrue(capacity(all) <= KnownTagCodec.serialLimit()); + assertTrue(capacity(all) >= KNOWN_IDS.size()); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java new file mode 100644 index 00000000000..c32823b740c --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/SizingHintTableHashOnlyForkedTest.java @@ -0,0 +1,59 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** + * With the dense store off, {@link SizingHintTable} hands out no hints and never allocates its + * lanes, and the hash store ignores a hint. Runs in its own JVM so the flag is read as off. + */ +class SizingHintTableHashOnlyForkedTest { + static { + System.setProperty("dd.trace.experimental.dense.tags.enabled", "false"); + } + + @Test + void noHintWhenTheDenseStoreIsOff() { + assertFalse(IdStoreFactory.Default.DENSE); + assertNull(SizingHintTable.hintFor("registry.off", true)); + assertNull(SizingHintTable.hintFor("registry.off", false)); + assertNull(SizingHintTable.hintFor(null, true)); + assertFalse(isLoaded("datadog.trace.api.SizingHintTable$Lanes")); + } + + @Test + void theHashStoreIgnoresAHint() { + SizingHint hint = new SizingHint("registry.off.direct"); + IdStore store = IdStore.create(null, hint); + store.set(KnownTags.COMPONENT_ID, "okhttp"); + store.set(KnownTags.DD_BASE_SERVICE_ID, "svc"); + store.recordSize(); + + assertSame(HashIdStore.class, store.getClass()); + assertEquals(SizingHintTable.SEED_SIZE, hint.size, "recordSize is a no-op on the hash store"); + assertFalse(isLoaded("datadog.trace.api.DenseIdStore")); + } + + /** Whether {@code className} has been loaded by TagMap's class loader, without loading it. */ + private static boolean isLoaded(String className) { + Method findLoadedClass; + try { + findLoadedClass = ClassLoader.class.getDeclaredMethod("findLoadedClass", String.class); + findLoadedClass.setAccessible(true); + } catch (RuntimeException | NoSuchMethodException e) { + assumeTrue(false, "cannot inspect loaded classes on this JVM: " + e); + return false; + } + try { + return findLoadedClass.invoke(TagMap.class.getClassLoader(), className) != null; + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java index 97b5f64e415..5709d45e81b 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagStoresTest.java @@ -162,6 +162,73 @@ void aClearSlotBitIsADefiniteMiss() { assertEquals("/a", child.getValue(KnownTags.HTTP_ROUTE_ID)); } + /** A frozen-style source: a parentless dense store holding {@code ids}, each set to its name. */ + private static DenseIdStore denseSource(long... ids) { + DenseIdStore source = new DenseIdStore(null); + for (long id : ids) { + source.set(id, KnownTagCodec.nameOf(id)); + } + return source; + } + + @Test + void seedAbsentCopiesEveryTagWhenNoneMayBePresent() { + DenseIdStore source = denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID); + DenseIdStore target = new DenseIdStore(null); + target.set(KnownTags.HTTP_ROUTE_ID, "/a"); + assertEquals( + 0L, source.spanOccupancy & target.spanOccupancy, "test precondition: disjoint slots"); + + assertTrue(target.seedAbsent(source)); + assertEquals(3, target.size()); + assertEquals("component", target.getValue(KnownTags.COMPONENT_ID)); + assertEquals("span.kind", target.getValue(KnownTags.SPAN_KIND_ID)); + assertEquals("/a", target.getValue(KnownTags.HTTP_ROUTE_ID)); + target.checkIntegrity(); + } + + @Test + void seedAbsentRefusesWhenAnyTagMayBePresent() { + DenseIdStore source = denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID); + DenseIdStore target = new DenseIdStore(null); + target.set(KnownTags.COMPONENT_ID, "explicit"); + + assertFalse(target.seedAbsent(source)); + assertEquals(1, target.size(), "nothing copied"); + assertEquals("explicit", target.getValue(KnownTags.COMPONENT_ID)); + } + + @Test + void seedAbsentRefusesASourceWithAParentOrTombstones() { + DenseIdStore parent = denseSource(KnownTags.COMPONENT_ID); + DenseIdStore child = new DenseIdStore(parent); + child.set(KnownTags.SPAN_KIND_ID, "server"); + assertFalse(new DenseIdStore(null).seedAbsent(child), "read-through source"); + + DenseIdStore tombstoned = new DenseIdStore(parent); + tombstoned.remove(KnownTags.COMPONENT_ID); + assertFalse(new DenseIdStore(null).seedAbsent(tombstoned), "tombstoned source"); + } + + @Test + void seedAbsentGrowsTheTargetToFit() { + DenseIdStore source = + denseSource(KnownTags.COMPONENT_ID, KnownTags.SPAN_KIND_ID, KnownTags.HTTP_ROUTE_ID); + DenseIdStore target = new DenseIdStore(null); // shared empty arrays: no room yet + + assertTrue(target.seedAbsent(source)); + assertEquals(3, target.size()); + assertEquals("http.route", target.getValue(KnownTags.HTTP_ROUTE_ID)); + target.checkIntegrity(); + } + + @Test + void theHashStoreNeverSeedsInBulk() { + HashIdStore target = new HashIdStore(null); + assertFalse(target.seedAbsent(denseSource(KnownTags.COMPONENT_ID))); + assertFalse(target.seedAbsent(new HashIdStore(null))); + } + @Test void aTraceLevelTagDoesNotDefeatASpanLevelMissInTheSameSlot() { // A local root span holds trace-level and span-level tags in one store, and the two levels are diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index a6733fc287e..d11fceadb36 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -5856,7 +5856,7 @@ { "version": "A", "type": "boolean", - "default": "false", + "default": "true", "aliases": [] } ],