diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java new file mode 100644 index 00000000000..e9e2d73b517 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/HeaderPathBenchmark.java @@ -0,0 +1,232 @@ +package datadog.trace.core; + +import static datadog.context.Context.root; +import static datadog.trace.bootstrap.instrumentation.api.AgentSpan.fromContext; +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.context.Context; +import datadog.context.ContextScope; +import datadog.context.propagation.CarrierSetter; +import datadog.context.propagation.CarrierVisitor; +import datadog.context.propagation.Propagators; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiConsumer; +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.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * One whole request per op, through the header code paths: extract the inbound headers with the + * default propagator, start the server span from what was extracted, run a few children, make one + * outbound HTTP call that injects its headers, then finish everything. + * + *

The existing propagation benchmarks each measure one step (extracting into a context, + * injecting from a prebuilt one, parsing one header value). They can't see costs that the extracted + * state causes later, on every span of the trace: for example, a {@code tracestate} that leads each + * span to allocate sampling state. Measuring the whole request catches those. + * + *

{@code headers} selects the inbound headers, under the default propagation styles: + * + *

+ */ +@State(Scope.Benchmark) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") +public class HeaderPathBenchmark { + static final int JDBC_SPANS = 3; + + static final String TRACE_ID_HEX = "66a0f6c800000000a3ce929d0e0e4736"; + static final String TRACE_ID_LOW = "11803532876627986230"; // a3ce929d0e0e4736 + static final String PARENT_ID = "67667974448284343"; // 00f067aa0ba902b7 + static final String PARENT_ID_HEX = "00f067aa0ba902b7"; + + static final String SERVER_INSTRUMENTATION = "tomcat-server"; + static final CharSequence SERVER_OPERATION = UTF8BytesString.create("servlet.request"); + static final String JDBC_INSTRUMENTATION = "java-jdbc-prepared_statement"; + static final CharSequence JDBC_OPERATION = UTF8BytesString.create("database.query"); + static final String CLIENT_INSTRUMENTATION = "okhttp"; + static final CharSequence CLIENT_OPERATION = UTF8BytesString.create("okhttp.request"); + + static final CarrierVisitor> GETTER = + new CarrierVisitor>() { + @Override + public void forEachKeyValue( + Map carrier, BiConsumer visitor) { + carrier.forEach(visitor); + } + }; + static final CarrierSetter> SETTER = Map::put; + + @Param({"none", "datadog", "tracecontext", "both", "bothBaggage"}) + String headers; + + CoreTracer tracer; + Map inbound; + SpanPrototype serverProto; + SpanPrototype jdbcProto; + SpanPrototype clientProto; + + /** One outbound carrier per thread, reused across requests. */ + @State(Scope.Thread) + public static class Outbound { + final Map headers = new HashMap<>(); + } + + @Setup + public void setup(Blackhole blackhole) { + this.tracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build(); + this.inbound = inboundHeaders(headers); + this.serverProto = + SpanPrototype.builder() + .initInstrumentationName(SERVER_INSTRUMENTATION) + .initOperationName(SERVER_OPERATION) + .initSpanType("web") + .initComponentAndIntegration("tomcat-server") + .initKind(Tags.SPAN_KIND_SERVER) + .initTag("language", "jvm") + .build(); + this.jdbcProto = + SpanPrototype.builder() + .initInstrumentationName(JDBC_INSTRUMENTATION) + .initOperationName(JDBC_OPERATION) + .initSpanType("sql") + .initComponentAndIntegration("java-jdbc") + .initKind(Tags.SPAN_KIND_CLIENT) + .build(); + this.clientProto = + SpanPrototype.builder() + .initInstrumentationName(CLIENT_INSTRUMENTATION) + .initOperationName(CLIENT_OPERATION) + .initSpanType("http") + .initComponentAndIntegration("okhttp") + .initKind(Tags.SPAN_KIND_CLIENT) + .build(); + checkPropagation(); + } + + /** + * Fails fast if the headers aren't actually extracted and injected, or if the outbound call + * doesn't continue the extracted trace, either of which would measure something else. + */ + void checkPropagation() { + Context parent = Propagators.defaultPropagator().extract(root(), inbound, GETTER); + if (!headers.equals("none") && fromContext(parent) == null) { + throw new IllegalStateException("nothing extracted from " + headers + " headers"); + } + Outbound outbound = new Outbound(); + request(outbound); + String injectedTraceId = outbound.headers.get("x-datadog-trace-id"); + if (injectedTraceId == null) { + throw new IllegalStateException("nothing injected: " + outbound.headers); + } + if (!headers.equals("none") && !injectedTraceId.equals(TRACE_ID_LOW)) { + throw new IllegalStateException( + "outbound call didn't continue the extracted trace: " + outbound.headers); + } + } + + @TearDown + public void tearDown() { + this.tracer.close(); + } + + static Map inboundHeaders(String style) { + Map h = new HashMap<>(); + h.put("host", "localhost:8080"); + h.put("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0"); + h.put("accept", "application/json"); + if (style.equals("datadog") || style.startsWith("both")) { + h.put("x-datadog-trace-id", TRACE_ID_LOW); + h.put("x-datadog-parent-id", PARENT_ID); + h.put("x-datadog-sampling-priority", "1"); + h.put("x-datadog-tags", "_dd.p.dm=-0,_dd.p.tid=66a0f6c800000000"); + } + if (style.equals("tracecontext") || style.startsWith("both")) { + h.put("traceparent", "00-" + TRACE_ID_HEX + "-" + PARENT_ID_HEX + "-01"); + h.put( + "tracestate", + "dd=s:1;p:" + + PARENT_ID_HEX + + ";t.dm:-0;t.tid:66a0f6c800000000," + + "ot=th:8;rv:9b8233f7e3a151,rojo=00f067aa0ba902b7"); + } + if (style.equals("bothBaggage")) { + h.put("baggage", "userId=alice,serverNode=DF%2028,isProduction=false"); + } + return h; + } + + @Benchmark + public void request(Outbound outbound) { + outbound.headers.clear(); + // HttpServerDecorator.extract / startSpan + Context parent = Propagators.defaultPropagator().extract(root(), inbound, GETTER); + AgentSpan extractedSpan = fromContext(parent); + AgentSpanContext extracted = extractedSpan == null ? null : extractedSpan.spanContext(); + AgentSpan server = tracer.startSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION, extracted); + server.setMeasured(true); + server.applyOverwriting(serverProto); + server.setTag(Tags.HTTP_METHOD, "GET"); + server.setTag(Tags.HTTP_URL, "http://localhost:8080/owners/42"); + server.setTag(Tags.HTTP_ROUTE, "/owners/{ownerId}"); + server.setTag(Tags.PEER_PORT, 54321); + + try (ContextScope serverScope = tracer.attach(parent.with(server))) { + for (int i = 0; i < JDBC_SPANS; ++i) { + AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); + jdbc.applyOverwriting(jdbcProto); + jdbc.setTag(Tags.DB_TYPE, "postgresql"); + jdbc.setTag(Tags.DB_INSTANCE, "petclinic"); + jdbc.setTag(Tags.PEER_HOSTNAME, "db.internal"); + jdbc.setResourceName("SELECT * FROM owners WHERE id = ?"); + try (ContextScope s = tracer.activateSpan(jdbc)) { + // the statement executes + } + jdbc.finish(); + } + + // the outbound call: start the client span, inject its headers, finish + AgentSpan client = tracer.startSpan(CLIENT_INSTRUMENTATION, CLIENT_OPERATION); + client.applyOverwriting(clientProto); + client.setTag(Tags.HTTP_METHOD, "GET"); + client.setTag(Tags.HTTP_URL, "http://inventory.internal/items/7"); + client.setTag(Tags.PEER_HOSTNAME, "inventory.internal"); + client.setTag(Tags.PEER_PORT, 8080); + Propagators.defaultPropagator() + .inject(tracer.currentContext().with(client), outbound.headers, SETTER); + client.setHttpStatusCode(200); + client.finish(); + } + server.setHttpStatusCode(200); + server.finish(); + } +} 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 new file mode 100644 index 00000000000..46e2887084e --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/TraceShapeBenchmark.java @@ -0,0 +1,284 @@ +package datadog.trace.core; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.context.ContextScope; +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.SpanPrototype; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +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.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * One whole trace per op, shaped like a web request: a server root, {@code children} jdbc + * prepared-statement children and one {@code @Trace} internal span, all finished. A trace has + * {@code children + 2} spans. + * + *

{@code children} varies the trace size, because single-span and small-trace results can hide + * costs that land on every child span: a change can make the root cheaper while charging each + * child, which only the larger traces show. + * + *

For retrospective comparison across tracer versions, each version keeps its own copy of this + * benchmark, so the arms reflect that version's code: + * + *

    + *
  • {@code legacy}: every tag set by name. It runs unchanged on every version, so it isolates + * the tracer's own costs. + *
  • {@code normal}: the calls this version's decorators make (copied from {@code + * HttpServerDecorator}, {@code JDBCDecorator} / {@code DatabaseClientDecorator} and {@code + * BaseDecorator} at this version), with request parsing replaced by fixed values. This is the + * version's real cost. + *
  • {@code newApi}: this version's newest span APIs, where they exist: here, construction from + * a {@link SpanPrototype} and tags set by id. + *
+ * + *

Tag values are realistic on purpose: the client port, the database port and the status fall + * 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). + */ +@State(Scope.Benchmark) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") +public class TraceShapeBenchmark { + /** The number of jdbc children, so the trace has {@code children + 2} spans. */ + @Param({"1", "5", "20"}) + int children; + + // server: HttpServerDecorator (tomcat) + static final String SERVER_INSTRUMENTATION = "tomcat-server"; + static final CharSequence SERVER_OPERATION = UTF8BytesString.create("servlet.request"); + static final CharSequence SERVER_COMPONENT = UTF8BytesString.create("tomcat-server"); + static final CharSequence WEB = UTF8BytesString.create("web"); + static final String USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0"; + static final String METHOD = "GET"; + static final String URL = "http://localhost:8080/owners/42"; + static final String HOSTNAME = "localhost"; + static final String CLIENT_IP = "127.0.0.1"; + static final int CLIENT_PORT = 54321; // ephemeral, outside the box cache + static final String ROUTE = "/owners/{ownerId}"; + static final CharSequence SERVER_RESOURCE = UTF8BytesString.create("GET /owners/{ownerId}"); + static final int STATUS = 200; // outside the box cache + + // jdbc: JDBCDecorator over a postgres prepared statement + static final String JDBC_INSTRUMENTATION = "java-jdbc-prepared_statement"; + static final CharSequence JDBC_OPERATION = UTF8BytesString.create("database.query"); + static final CharSequence JDBC_RENAMED_OPERATION = UTF8BytesString.create("postgresql.query"); + static final CharSequence JAVA_JDBC = UTF8BytesString.create("java-jdbc"); + static final CharSequence JDBC_PREPARED_STATEMENT = + UTF8BytesString.create("java-jdbc-prepared_statement"); + static final CharSequence SQL = UTF8BytesString.create("sql"); + static final String DB_TYPE = "postgresql"; + static final String DB_SERVICE = "postgresql"; + static final String DB_POOL = "HikariPool-1"; + static final String DB_USER = "app"; + static final String DB_INSTANCE = "petclinic"; + static final String DB_HOST = "db.internal"; + static final String DB_OPERATION = "SELECT"; + static final CharSequence STATEMENT = UTF8BytesString.create("SELECT * FROM owners WHERE id = ?"); + + // internal: TraceDecorator (@Trace) + static final String TRACE_INSTRUMENTATION = "trace"; + static final CharSequence TRACE_OPERATION = UTF8BytesString.create("trace.annotation"); + static final CharSequence TRACE_COMPONENT = UTF8BytesString.create("trace"); + static final CharSequence TRACE_RESOURCE = UTF8BytesString.create("OwnerService.findOwner"); + + CoreTracer tracer; + + SpanPrototype serverProto; + SpanPrototype jdbcProto; + SpanPrototype traceProto; + + @Setup + public void setup(Blackhole blackhole) { + this.tracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build(); + // the decorators' prototype chains: Base (type, component) -> Server / Client (kind, language) + this.serverProto = + SpanPrototype.builder() + .initInstrumentationName(SERVER_INSTRUMENTATION) + .initOperationName(SERVER_OPERATION) + .initSpanType(WEB) + .initComponentAndIntegration(SERVER_COMPONENT) + .initKind(Tags.SPAN_KIND_SERVER) + .initTag("language", "jvm") + .build(); + this.jdbcProto = + SpanPrototype.builder() + .initInstrumentationName(JDBC_INSTRUMENTATION) + .initOperationName(JDBC_OPERATION) + .initSpanType(SQL) + .initComponentAndIntegration(JAVA_JDBC) + .initKind(Tags.SPAN_KIND_CLIENT) + .build(); + this.traceProto = + SpanPrototype.builder() + .initInstrumentationName(TRACE_INSTRUMENTATION) + .initOperationName(TRACE_OPERATION) + .initComponentAndIntegration(TRACE_COMPONENT) + .build(); + } + + @TearDown + public void tearDown() { + this.tracer.close(); + } + + // ---- legacy: every tag by name ---- + + @Benchmark + public void legacy() { + AgentSpan server = tracer.startSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION); + server.setSpanType(WEB); + server.setTag(Tags.COMPONENT, SERVER_COMPONENT); + server.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_SERVER); + server.setTag("language", "jvm"); + legacyRequest(server); + try (ContextScope scope = tracer.activateSpan(server)) { + for (int i = 0; i < children; ++i) { + AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); + jdbc.setSpanType(SQL); + jdbc.setTag(Tags.COMPONENT, JAVA_JDBC); + jdbc.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_CLIENT); + legacyJdbc(jdbc); + finishScoped(jdbc); + } + AgentSpan internal = tracer.startSpan(TRACE_INSTRUMENTATION, TRACE_OPERATION); + internal.setTag(Tags.COMPONENT, TRACE_COMPONENT); + internal.setResourceName(TRACE_RESOURCE); + finishScoped(internal); + } + server.setHttpStatusCode(STATUS); + server.finish(); + } + + static void legacyRequest(AgentSpan server) { + server.setTag(Tags.HTTP_USER_AGENT, USER_AGENT); + server.setTag(Tags.HTTP_METHOD, METHOD); + server.setTag(Tags.HTTP_URL, URL); + server.setTag(Tags.HTTP_HOSTNAME, HOSTNAME); + server.setResourceName(SERVER_RESOURCE); + server.setTag(Tags.PEER_HOST_IPV4, CLIENT_IP); + server.setTag(Tags.PEER_PORT, CLIENT_PORT); + server.setTag(Tags.HTTP_ROUTE, ROUTE); + } + + static void legacyJdbc(AgentSpan jdbc) { + jdbc.setTag(Tags.DB_TYPE, DB_TYPE); + jdbc.setServiceName(DB_SERVICE, JAVA_JDBC); + jdbc.setOperationName(JDBC_RENAMED_OPERATION); + jdbc.setTag(Tags.DB_POOL_NAME, DB_POOL); + jdbc.setTag(Tags.DB_USER, DB_USER); + jdbc.setTag(Tags.DB_INSTANCE, DB_INSTANCE); + jdbc.setTag(Tags.PEER_HOSTNAME, DB_HOST); + jdbc.setResourceName(STATEMENT); + jdbc.setTag(Tags.DB_OPERATION, DB_OPERATION); + jdbc.setTag(Tags.COMPONENT, JDBC_PREPARED_STATEMENT); + } + + // ---- normal: what this version's decorators do ---- + + @Benchmark + public void normal() { + // HttpServerDecorator: startSpan, afterStart, onRequest, then onResponseStatus at the end + AgentSpan server = tracer.startSpan(SERVER_INSTRUMENTATION, SERVER_OPERATION); + server.applyOverwriting(serverProto); // BaseDecorator.afterStart + server.setTag(Tags.HTTP_USER_AGENT, USER_AGENT); + server.setTag(Tags.HTTP_METHOD, METHOD); + server.setTag(Tags.HTTP_URL, URL); + server.setTag(Tags.HTTP_HOSTNAME, HOSTNAME); + server.setResourceName(SERVER_RESOURCE); // HttpResourceDecorator.withServerPath + server.setTag(Tags.PEER_HOST_IPV4, CLIENT_IP); + server.setTag(Tags.PEER_PORT, CLIENT_PORT); // setPeerPort + server.setTag(Tags.HTTP_ROUTE, ROUTE); // the framework, once routing resolves + try (ContextScope scope = tracer.activateSpan(server)) { + for (int i = 0; i < children; ++i) { + // AbstractPreparedStatementInstrumentation: startSpan, afterStart, onConnection, + // onPreparedStatement + AgentSpan jdbc = tracer.startSpan(JDBC_INSTRUMENTATION, JDBC_OPERATION); + jdbc.applyOverwriting(jdbcProto); + jdbc.setTag(Tags.DB_TYPE, DB_TYPE); // processDatabaseType + jdbc.setServiceName(DB_SERVICE, JAVA_JDBC); // postProcessServiceAndOperationName + jdbc.setOperationName(JDBC_RENAMED_OPERATION); + jdbc.setTag(Tags.DB_POOL_NAME, DB_POOL); + jdbc.setTag(Tags.DB_USER, DB_USER); // DatabaseClientDecorator.onConnection + jdbc.setTag(Tags.DB_INSTANCE, DB_INSTANCE); + jdbc.setTag(Tags.PEER_HOSTNAME, DB_HOST); + jdbc.setResourceName(STATEMENT); // withQueryInfo + jdbc.setTag(Tags.DB_OPERATION, DB_OPERATION); + jdbc.spanContext().setIntegrationName(JDBC_PREPARED_STATEMENT); + jdbc.setTag(Tags.COMPONENT, JDBC_PREPARED_STATEMENT); + finishScoped(jdbc); + } + AgentSpan internal = tracer.startSpan(TRACE_INSTRUMENTATION, TRACE_OPERATION); + internal.applyOverwriting(traceProto); + internal.setResourceName(TRACE_RESOURCE); + finishScoped(internal); + } + server.setHttpStatusCode(STATUS); // onResponseStatus + server.finish(); + } + + // ---- newApi: construction from a prototype, tags by id ---- + + @Benchmark + public void newApi() { + AgentSpan server = tracer.startSpan(serverProto, SERVER_OPERATION); + server.setTag(KnownTags.HTTP_USERAGENT_ID, USER_AGENT); + server.setTag(KnownTags.HTTP_METHOD_ID, METHOD); + server.setTag(KnownTags.HTTP_URL_ID, URL); + server.setTag(KnownTags.HTTP_HOSTNAME_ID, HOSTNAME); + server.setResourceName(SERVER_RESOURCE); + server.setTag(KnownTags.PEER_IPV4_ID, CLIENT_IP); + server.setTag(KnownTags.PEER_PORT_ID, CLIENT_PORT); + server.setTag(KnownTags.HTTP_ROUTE_ID, ROUTE); + try (ContextScope scope = tracer.activateSpan(server)) { + for (int i = 0; i < children; ++i) { + AgentSpan jdbc = tracer.startSpan(jdbcProto, JDBC_OPERATION); + jdbc.setTag(KnownTags.DB_TYPE_ID, DB_TYPE); + jdbc.setServiceName(DB_SERVICE, JAVA_JDBC); + jdbc.setOperationName(JDBC_RENAMED_OPERATION); + jdbc.setTag(KnownTags.DB_POOL_NAME_ID, DB_POOL); + jdbc.setTag(KnownTags.DB_USER_ID, DB_USER); + jdbc.setTag(KnownTags.DB_INSTANCE_ID, DB_INSTANCE); + jdbc.setTag(KnownTags.PEER_HOSTNAME_ID, DB_HOST); + jdbc.setResourceName(STATEMENT); + jdbc.setTag(KnownTags.DB_OPERATION_ID, DB_OPERATION); + jdbc.spanContext().setIntegrationName(JDBC_PREPARED_STATEMENT); + jdbc.setTag(KnownTags.COMPONENT_ID, JDBC_PREPARED_STATEMENT); + finishScoped(jdbc); + } + AgentSpan internal = tracer.startSpan(traceProto, TRACE_OPERATION); + internal.setResourceName(TRACE_RESOURCE); + finishScoped(internal); + } + server.setHttpStatusCode(STATUS); + server.finish(); + } + + /** The advice's exit: activate around the call, then close and finish. */ + void finishScoped(AgentSpan span) { + ContextScope scope = tracer.activateSpan(span); + scope.close(); + span.finish(); + } +} 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 24b652e7593..62cbd8dde77 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 @@ -19,6 +19,7 @@ import datadog.trace.api.KnownTags; import datadog.trace.api.ProcessTags; import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.api.cache.DDCache; import datadog.trace.api.cache.DDCaches; import datadog.trace.api.config.TracerConfig; @@ -68,7 +69,7 @@ * the associated Span instance */ public class DDSpanContext - implements AgentSpanContext, RequestContext, TraceSegment, ProfilerContext { + implements AgentSpanContext, RequestContext, TraceSegment, ProfilerContext, TagStore { private static final Logger log = LoggerFactory.getLogger(DDSpanContext.class); public static final String PRIORITY_SAMPLING_KEY = "_sampling_priority_v1"; @@ -1395,6 +1396,50 @@ Object getTag(final String key) { } } + /* + * TagStore: the span's tags as stored, loaded and stored with no locking, interception or + * interpretation -- the storage beneath the span, as a Map was beneath a TagMap. A span isn't + * conceptually a TagStore. It implements one so post-processing is handed the span's storage + * without allocating: a wrapper would be free only if escape analysis removed it, and the + * post-processor chain's dispatch loop defeats that. Callers hold the span's tag lock, as + * post-processing does. + */ + + @Override + public Object load(String tag) { + return unsafeTags.getObject(tag); + } + + @Override + public String loadString(String tag) { + return unsafeTags.getString(tag); + } + + @Override + public Object load(long tagId) { + return unsafeTags.load(tagId); + } + + @Override + public String loadString(long tagId) { + return unsafeTags.loadString(tagId); + } + + @Override + public void store(String tag, Object value) { + unsafeTags.set(tag, value); + } + + @Override + public void store(long tagId, Object value) { + unsafeTags.set(tagId, value); + } + + @Override + public void store(TagMap.EntryReader entry) { + unsafeTags.set(entry); + } + /** * This is not thread-safe and must only be used when it can be guaranteed that the context will * not be mutated. This is internal API and must not be exposed to users. @@ -1500,7 +1545,7 @@ public void setMetaStruct(final String field, final T value) { void earlyProcessTags(AppendableSpanLinks links) { synchronized (unsafeTags) { - TagsPostProcessorFactory.eagerProcessor().processTags(unsafeTags, this, links); + TagsPostProcessorFactory.eagerProcessor().processTags(this, this, links); } } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerPostProcessorChain.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerPostProcessorChain.java new file mode 100644 index 00000000000..df1ee6ea25d --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerPostProcessorChain.java @@ -0,0 +1,24 @@ +package datadog.trace.core.tagprocessor; + +import datadog.trace.api.TagStore; +import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; +import datadog.trace.core.DDSpanContext; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Runs eager post-processors in order. */ +public final class EagerPostProcessorChain extends EagerTagsPostProcessor { + private final EagerTagsPostProcessor[] chain; + + public EagerPostProcessorChain(@Nonnull final EagerTagsPostProcessor... processors) { + chain = Objects.requireNonNull(processors); + } + + @Override + public void processTags( + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + for (final EagerTagsPostProcessor processor : chain) { + processor.processTags(unsafeTags, spanContext, spanLinks); + } + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerTagsPostProcessor.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerTagsPostProcessor.java new file mode 100644 index 00000000000..15b1dcf4e1a --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/EagerTagsPostProcessor.java @@ -0,0 +1,23 @@ +package datadog.trace.core.tagprocessor; + +import datadog.trace.api.TagStore; +import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; +import datadog.trace.core.DDSpanContext; + +/** + * A tag post-processor run on the application thread as a span finishes. It works on the span's + * tags as stored, through a {@link TagStore}, rather than a {@code TagMap} the span would have to + * build for it. The span passes itself as that store, which allocates nothing. + */ +public abstract class EagerTagsPostProcessor { + /** + * Processes the span's tags. + * + * @param unsafeTags the span's tags as stored, to read and write; the caller holds the span's tag + * lock + * @param spanContext the span, for its other properties + * @param spanLinks the span's links + */ + public abstract void processTags( + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks); +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java index 2bb29fe31f4..cf278c16831 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/HttpEndpointPostProcessor.java @@ -1,10 +1,10 @@ package datadog.trace.core.tagprocessor; +import static datadog.trace.api.KnownTags.HTTP_METHOD_ID; +import static datadog.trace.api.KnownTags.HTTP_ROUTE_ID; import static datadog.trace.bootstrap.instrumentation.api.OtelHttpSemantics.urlOrPath; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_METHOD; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ROUTE; -import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.api.endpoint.EndpointResolver; import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; @@ -30,7 +30,7 @@ *

  • If {@code alwaysSimplifiedEndpoint=true}, always compute from URL regardless of route * */ -public class HttpEndpointPostProcessor extends TagsPostProcessor { +public class HttpEndpointPostProcessor extends EagerTagsPostProcessor { private static final Logger log = LoggerFactory.getLogger(HttpEndpointPostProcessor.class); private final EndpointResolver endpointResolver; @@ -55,18 +55,18 @@ public HttpEndpointPostProcessor() { @Override public void processTags( - TagMap unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { if (!endpointResolver.isEnabled()) { log.debug("EndpointResolver is not enabled, skipping HTTP endpoint post processing"); return; } - if (unsafeTags.getObject(HTTP_METHOD) == null) { + if (unsafeTags.load(HTTP_METHOD_ID) == null) { return; } try { - String httpRoute = unsafeTags.getString(HTTP_ROUTE); + String httpRoute = unsafeTags.loadString(HTTP_ROUTE_ID); Object httpUrl = urlOrPath(unsafeTags); endpointResolver.resolveEndpoint( unsafeTags, httpRoute, httpUrl == null ? null : httpUrl.toString()); diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/InternalTagsAdder.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/InternalTagsAdder.java index 3e6b8b13192..b0f8184e5d9 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/InternalTagsAdder.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/InternalTagsAdder.java @@ -1,16 +1,17 @@ package datadog.trace.core.tagprocessor; -import static datadog.trace.bootstrap.instrumentation.api.Tags.VERSION; +import static datadog.trace.api.KnownTags.DD_BASE_SERVICE_ID; +import static datadog.trace.api.KnownTags.VERSION_ID; -import datadog.trace.api.DDTags; import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.core.DDSpanContext; import javax.annotation.Nonnull; import javax.annotation.Nullable; -public final class InternalTagsAdder extends TagsPostProcessor { +public final class InternalTagsAdder extends EagerTagsPostProcessor { private final UTF8BytesString ddService; // Prebuilt once to avoid per-span Entry allocation. @@ -19,16 +20,16 @@ public final class InternalTagsAdder extends TagsPostProcessor { public InternalTagsAdder(@Nonnull final String ddService, @Nullable final String version) { this.ddService = UTF8BytesString.create(ddService); - this.baseServiceEntry = TagMap.Entry.create(DDTags.BASE_SERVICE, this.ddService); + this.baseServiceEntry = TagMap.Entry.create(DD_BASE_SERVICE_ID, this.ddService); this.versionEntry = version != null && !version.isEmpty() - ? TagMap.Entry.create(VERSION, UTF8BytesString.create(version)) + ? TagMap.Entry.create(VERSION_ID, UTF8BytesString.create(version)) : null; } @Override public void processTags( - TagMap unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { if (spanContext == null) { return; } @@ -36,13 +37,13 @@ public void processTags( if (!ddService.toString().equalsIgnoreCase(spanContext.getServiceName())) { if (baseServiceEntry != null) { // service name != DD_SERVICE - unsafeTags.set(baseServiceEntry); + unsafeTags.store(baseServiceEntry); } } else { // as per config consistency, the version tag is added across tracers only if // the service name is DD_SERVICE and version tag is not manually set - if (versionEntry != null && !unsafeTags.containsKey(VERSION)) { - unsafeTags.set(versionEntry); + if (versionEntry != null && unsafeTags.load(VERSION_ID) == null) { + unsafeTags.store(versionEntry); } } } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PayloadTagsProcessor.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PayloadTagsProcessor.java index 09d382843a3..e254a756a87 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PayloadTagsProcessor.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PayloadTagsProcessor.java @@ -245,6 +245,7 @@ public void doubleValue(PathCursor path, double value) { @Override public void nullValue(PathCursor path) { + // a JSON null sets no tag, and removes an earlier value set at the same path collectedTags.set(path.toString(tagPrefix), null); } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PeerServiceCalculator.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PeerServiceCalculator.java index 198e2c78f1c..98fd6888d45 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PeerServiceCalculator.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/PeerServiceCalculator.java @@ -1,18 +1,19 @@ package datadog.trace.core.tagprocessor; +import static datadog.trace.api.KnownTags.DD_PEER_SERVICE_REMAPPED_FROM_ID; +import static datadog.trace.api.KnownTags.PEER_SERVICE_ID; + import datadog.trace.api.Config; -import datadog.trace.api.DDTags; -import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.api.naming.NamingSchema; import datadog.trace.api.naming.SpanNaming; import datadog.trace.bootstrap.instrumentation.api.AppendableSpanLinks; -import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.core.DDSpanContext; import java.util.Map; import javax.annotation.Nonnull; -public final class PeerServiceCalculator extends TagsPostProcessor { +public final class PeerServiceCalculator extends EagerTagsPostProcessor { private final NamingSchema.ForPeerService peerServiceNaming; private final Map peerServiceMapping; @@ -34,8 +35,8 @@ public PeerServiceCalculator() { @Override public void processTags( - TagMap unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { - Object peerService = unsafeTags.getObject(Tags.PEER_SERVICE); + TagStore unsafeTags, DDSpanContext spanContext, AppendableSpanLinks spanLinks) { + Object peerService = unsafeTags.load(PEER_SERVICE_ID); // the user set it if (peerService != null) { if (canRemap) { @@ -46,18 +47,18 @@ public void processTags( // calculate the defaults (if any) peerServiceNaming.tags(unsafeTags); // only remap if the mapping is not empty (saves one get) - remapPeerService(unsafeTags, canRemap ? unsafeTags.getObject(Tags.PEER_SERVICE) : null); + remapPeerService(unsafeTags, canRemap ? unsafeTags.load(PEER_SERVICE_ID) : null); return; } // we have no peer.service and we do not compute defaults. Leave the map untouched } - private void remapPeerService(TagMap unsafeTags, Object value) { + private void remapPeerService(TagStore unsafeTags, Object value) { if (value != null) { String mapped = peerServiceMapping.get(value); if (mapped != null) { - unsafeTags.put(Tags.PEER_SERVICE, mapped); - unsafeTags.put(DDTags.PEER_SERVICE_REMAPPED_FROM, value); + unsafeTags.store(PEER_SERVICE_ID, mapped); + unsafeTags.store(DD_PEER_SERVICE_REMAPPED_FROM_ID, value); } } } diff --git a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java index 08c52a2658a..2e3cd4ca345 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/tagprocessor/TagsPostProcessorFactory.java @@ -9,11 +9,11 @@ public final class TagsPostProcessorFactory { private static boolean addRemoteHostname = true; private static class Lazy { - private static TagsPostProcessor eagerProcessor = createEagerChain(); + private static EagerTagsPostProcessor eagerProcessor = createEagerChain(); private static TagsPostProcessor lazyProcessor = createLazyChain(); - private static TagsPostProcessor createEagerChain() { - final List processors = new ArrayList<>(3); + private static EagerTagsPostProcessor createEagerChain() { + final List processors = new ArrayList<>(3); processors.add(new PeerServiceCalculator()); if (addInternalTags) { processors.add( @@ -24,7 +24,7 @@ private static TagsPostProcessor createEagerChain() { if (Config.get().isTraceResourceRenamingEnabled()) { processors.add(new HttpEndpointPostProcessor()); } - return new PostProcessorChain(processors.toArray(new TagsPostProcessor[0])); + return new EagerPostProcessorChain(processors.toArray(new EagerTagsPostProcessor[0])); } private static TagsPostProcessor createLazyChain() { @@ -54,7 +54,7 @@ private static TagsPostProcessor createLazyChain() { } } - public static TagsPostProcessor eagerProcessor() { + public static EagerTagsPostProcessor eagerProcessor() { return Lazy.eagerProcessor; } diff --git a/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java b/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java new file mode 100644 index 00000000000..fd6e413f4e8 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/TagStoreTest.java @@ -0,0 +1,87 @@ +package datadog.trace.core; + +import static datadog.trace.api.KnownTags.HTTP_ROUTE_ID; +import static datadog.trace.api.KnownTags.HTTP_STATUS_CODE_ID; +import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ROUTE; +import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_STATUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; +import datadog.trace.common.writer.ListWriter; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** A known tag's id and name address the same tag, in every {@link TagStore}. */ +class TagStoreTest extends DDCoreJavaSpecification { + private CoreTracer tracer; + + @BeforeEach + void setup() { + tracer = tracerBuilder().writer(new ListWriter()).build(); + } + + @AfterEach + void cleanup() { + tracer.close(); + } + + @Test + void storedByIdLoadsByName() { + forEachTagStore( + tags -> { + tags.store(HTTP_ROUTE_ID, "/users/{id}"); + + assertEquals("/users/{id}", tags.load(HTTP_ROUTE)); + assertEquals("/users/{id}", tags.load(HTTP_ROUTE_ID)); + }); + } + + @Test + void storedByNameLoadsById() { + forEachTagStore( + tags -> { + tags.store(HTTP_ROUTE, "/users/{id}"); + + assertEquals("/users/{id}", tags.load(HTTP_ROUTE_ID)); + }); + } + + @Test + void loadStringByIdConverts() { + forEachTagStore( + tags -> { + tags.store(HTTP_STATUS_CODE_ID, 404); + + assertEquals("404", tags.loadString(HTTP_STATUS_CODE_ID)); + assertEquals("404", tags.loadString(HTTP_STATUS)); + }); + } + + @Test + void nullStoredByIdRemoves() { + forEachTagStore( + tags -> { + tags.store(HTTP_ROUTE, "/users/{id}"); + + tags.store(HTTP_ROUTE_ID, null); + + assertNull(tags.load(HTTP_ROUTE)); + assertNull(tags.loadString(HTTP_ROUTE_ID)); + }); + } + + private void forEachTagStore(Consumer check) { + check.accept(TagMap.create()); + + DDSpan span = (DDSpan) tracer.buildSpan("test", "span").start(); + try { + check.accept(span.spanContext()); + } finally { + span.finish(); + } + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/tagprocessor/PayloadTagsProcessorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/tagprocessor/PayloadTagsProcessorTest.java index 1405c6329ae..cb91a78789e 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/tagprocessor/PayloadTagsProcessorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/tagprocessor/PayloadTagsProcessorTest.java @@ -203,8 +203,6 @@ void expandPreservingTagTypes() { 3.14d, "payload.tag4.Value.0", "string", - "payload.tag5", - null, "payload.tag6", false); assertEquals(expected, unsafeTags); @@ -246,8 +244,6 @@ void expandStringifiedJsonTags() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, "p.j3.4", true, "p.j4.foo", @@ -257,6 +253,23 @@ void expandStringifiedJsonTags() { assertEquals(expected, unsafeTags); } + @Test + void nullRemovesEarlierValueAtSamePath() { + PayloadTagsProcessor ptp = tagsProcessor("p", Collections.emptyList(), 10, 758); + + Map st = + spanTags( + "p", + pv(pc().push("j1"), "{'x': 'old', 'x': null, 'y': 1}"), + pv(pc().push("v"), "old"), + pv(pc().push("v"), null)); + + TagMap unsafeTags = TagMap.fromMap(st); + ptp.processTags(unsafeTags, null, link -> {}); + + assertEquals(mapOf("p.j1.y", 1), unsafeTags); + } + @Test void expandSerializedEscapedInnerJsonWithinInnerJson() throws IOException { Buffer b0 = new Buffer(); @@ -410,8 +423,6 @@ void applyRedactionRules() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, "p.j3.4", true, "p.j4.foo", @@ -444,8 +455,8 @@ void respectMaxTagsLimit() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, + "p.j3.4", + true, "_dd.payload_tags_incomplete", true), unsafeTags); @@ -474,8 +485,6 @@ void respectMaxDepthLimit() { 2, "p.j3.2", 3.14d, - "p.j3.3", - null, "p.j3.4", true, "p.j3.5.0", diff --git a/internal-api/src/main/java/datadog/trace/api/HashIdStore.java b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java new file mode 100644 index 00000000000..2ec53294d7f --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashIdStore.java @@ -0,0 +1,422 @@ +package datadog.trace.api; + +import datadog.trace.api.TagMap.BucketGroup; +import datadog.trace.api.TagMap.Entry; +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import javax.annotation.Nonnull; + +/** + * The hash {@link IdStore}: known tags in their own bucket array ({@link HashSupport}), keyed by + * id, reading through to the frozen parent's id store. + * + *

    The buckets are the exact state: a read probes them, and a local miss reads through to the + * parent if there is one. Removing an inherited tag puts that tag's shared tombstone ({@link + * Entry#tombstoneOf}) in the local buckets, so it allocates nothing. A tombstone is never counted, + * iterated or returned. + */ +final class HashIdStore extends IdStore { + /** An empty parentless store over one bucket, for {@link TagMap#EMPTY}, which is frozen. */ + static HashIdStore createEmpty() { + return new HashIdStore(new Object[1]); + } + + /** Walks the leaf's buckets, then each ancestor's, skipping tombstones and shadowed entries. */ + /** Walks the store's levels; it is its own bucket walker, so a cursor is one allocation. */ + private static final class HashCursor extends HashSupport.BucketWalker implements Cursor { + private final HashIdStore leaf; + private HashIdStore level; + + HashCursor(HashIdStore leaf) { + this.leaf = leaf; + this.level = leaf; + this.reset(leaf.buckets); + } + + @Override + public Entry next() { + HashIdStore level = this.level; + while (level != null) { + Entry entry = this.nextEntry(); + if (entry != null) { + if (!entry.tombstone + && (level == this.leaf || this.leaf.parentEntryVisible(entry, level))) { + return entry; + } + continue; + } + level = level.parent; + this.level = level; + if (level != null) { + this.reset(level.buckets); + } + } + return null; + } + } + + final Object[] buckets; + + /** Live local entries, not counting tombstones. */ + int count; + + /** The frozen parent's id store, or null. */ + final HashIdStore parent; + + /** Whether the buckets may hold a tombstone; a hint that keeps the common case on fast paths. */ + boolean hasTombstones; + + HashIdStore(IdStore parent) { + this.buckets = HashSupport.newBuckets(); + this.parent = (HashIdStore) parent; + } + + private HashIdStore(Object[] buckets) { + this.buckets = buckets; + this.parent = null; + } + + @Override + public Entry getEntry(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + return this.getVisible(tagId); + } + + /** The stored Entry is itself a stable reader, so this is {@link #getEntry}. */ + @Override + public Entry getEntryReader(long tagId) { + return this.getEntry(tagId); + } + + /** {@link #getEntry} for a validated id. */ + private Entry getVisible(long tagId) { + Entry local = HashSupport.findById(this.buckets, Entry.bucketHash(tagId), tagId); + if (local != null) { + return local.tombstone ? null : local; + } + HashIdStore parent = this.parent; + return parent == null ? null : parent.getVisible(tagId); + } + + @Override + public Object load(long tagId) { + Entry entry = this.getEntry(tagId); + return entry == null ? null : entry.objectValue(); + } + + @Override + public String loadString(long tagId) { + Entry entry = this.getEntry(tagId); + return entry == null ? null : entry.stringValue(); + } + + @Override + public void store(long tagId, @Nonnull Object value) { + this.put(Entry.newAnyEntry(tagId, value)); + } + + @Override + public void store(long tagId, @Nonnull CharSequence value) { + this.put(Entry.newObjectEntry(tagId, value)); + } + + @Override + public void store(long tagId, boolean value) { + this.put(Entry.newBooleanEntry(tagId, value)); + } + + @Override + public void store(long tagId, int value) { + this.put(Entry.newIntEntry(tagId, value)); + } + + @Override + public void store(long tagId, long value) { + this.put(Entry.newLongEntry(tagId, value)); + } + + @Override + public void store(long tagId, float value) { + this.put(Entry.newFloatEntry(tagId, value)); + } + + @Override + public void store(long tagId, double value) { + this.put(Entry.newDoubleEntry(tagId, value)); + } + + @Override + public Entry put(EntryReader entryReader) { + Entry entry = entryReader.entry(); + if (entry == null) { + return null; // a reader with no entry is a no-op, as for TagMap.set + } + Entry replaced = HashSupport.put(this.buckets, entry); + if (replaced == null || replaced.tombstone) { + this.count++; // a tombstone replaced is a removal undone: no prior local entry + return null; + } + return replaced; + } + + @Override + public Entry getAndPut(EntryReader entryReader) { + Entry entry = entryReader.entry(); + if (entry == null) { + return null; // a reader with no entry is a no-op + } + Entry replaced = HashSupport.put(this.buckets, entry); + if (replaced != null && !replaced.tombstone) { + return replaced; // replaced a local entry -> that is the prior value + } + this.count++; + HashIdStore parent = this.parent; + if (replaced != null || parent == null) { + return null; // it was tombstoned, so not visible, or nothing to inherit + } + return parent.getVisible(entry.tagHash); + } + + @Override + public Entry remove(long tagId) { + if (!KnownTagCodec.isKnown(tagId)) { + throw notKnown(tagId); + } + int hash = Entry.bucketHash(tagId); + + Entry local = HashSupport.findById(this.buckets, hash, tagId); + if (local != null) { + if (local.tombstone) { + return null; // already removed + } + HashSupport.remove(this.buckets, hash, local.tag); + this.count--; + } + HashIdStore parent = this.parent; + if (parent != null) { + // Read-through: hide an inherited value with a tombstone. The prior *visible* value is the + // local entry if there was one, otherwise the inherited one. + Entry inherited = parent.getVisible(tagId); + if (inherited != null) { + HashSupport.put(this.buckets, Entry.tombstoneOf(tagId)); + this.hasTombstones = true; + return local != null ? local : inherited; + } + } + return local; + } + + @Override + int size() { + return this.parent == null ? this.count : this.count + this.visibleParentCount(); + } + + /** Ancestor entries not shadowed or tombstoned by a nearer level, nearest-level-wins. */ + private int visibleParentCount() { + // a plain loop, not forEachParent: counting through a lambda would allocate a counter + int count = 0; + for (HashIdStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object bucket = parentBuckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone && this.parentEntryVisible(entry, ancestor)) { + count++; + } + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone && this.parentEntryVisible(entry, ancestor)) { + count++; + } + } + } + } + } + } + return count; + } + + @Override + boolean isEmpty() { + if (this.count != 0) { + return false; + } + HashIdStore parent = this.parent; + if (parent == null) { + return true; + } + if (!this.hasTombstones) { + return parent.isEmpty(); // nothing hidden: the parent's tags are all visible + } + return this.visibleParentCount() == 0; + } + + @Override + boolean isDefinitelyEmpty() { + for (HashIdStore level = this; level != null; level = level.parent) { + if (level.count != 0) { + return false; + } + } + return true; + } + + @Override + int estimateSize() { + int total = 0; + for (HashIdStore level = this; level != null; level = level.parent) { + total += level.count; + } + return total; + } + + @Override + boolean hasParent() { + return this.parent != null; + } + + /** + * Whether {@code parentEntry}, from the ancestor level {@code fromAncestor}, is visible through + * this leaf: no nearer level holds an entry or a tombstone for it. + */ + boolean parentEntryVisible(Entry parentEntry, HashIdStore fromAncestor) { + long tagId = parentEntry.tagHash; + int hash = parentEntry.hash(); + for (HashIdStore nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + if (HashSupport.findById(nearer.buckets, hash, tagId) != null) { + return false; + } + } + return true; + } + + @Override + void forEach(Consumer consumer) { + if (this.hasTombstones) { + HashSupport.forEachLive(this.buckets, consumer); + } else { + HashSupport.forEach(this.buckets, consumer); + } + if (this.parent != null) { + this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); + } + } + + @Override + void forEach(T thisObj, BiConsumer consumer) { + if (this.hasTombstones) { + HashSupport.forEachLive(this.buckets, thisObj, consumer); + } else { + HashSupport.forEach(this.buckets, thisObj, consumer); + } + if (this.parent != null) { + this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry)); + } + } + + @Override + void forEach(T thisObj, U otherObj, TriConsumer consumer) { + if (this.hasTombstones) { + HashSupport.forEachLive(this.buckets, thisObj, otherObj, consumer); + } else { + HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); + } + if (this.parent != null) { + this.forEachParent(thisObj, otherObj, consumer); + } + } + + private void forEachParent( + T thisObj, U otherObj, TriConsumer consumer) { + for (HashIdStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object bucket = parentBuckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone && this.parentEntryVisible(entry, ancestor)) { + consumer.accept(thisObj, otherObj, entry); + } + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone && this.parentEntryVisible(entry, ancestor)) { + consumer.accept(thisObj, otherObj, entry); + } + } + } + } + } + } + } + + @Override + Cursor cursor() { + return new HashCursor(this); + } + + @Override + IdStore copy() { + HashIdStore copy = new HashIdStore(this.parent); + HashSupport.copyInto(copy.buckets, this.buckets); + copy.count = this.count; + copy.hasTombstones = this.hasTombstones; + return copy; + } + + @Override + void putAllLocal(IdStore other) { + HashIdStore that = (HashIdStore) other; + if (that.count == 0) { + return; + } + if (this.hasTombstones) { + // A bucket merge would count a replaced tombstone as a replacement, so put one at a time. + HashSupport.forEachLive(that.buckets, this, (self, entryReader) -> self.put(entryReader)); + return; + } + if (this.count == 0) { + HashSupport.copyInto(this.buckets, that.buckets); + this.count = that.count; + } else { + this.count += HashSupport.merge(this.buckets, that.buckets); + } + } + + @Override + void fillMapLocal(Map map) { + HashSupport.fillMap(this.buckets, map); + } + + @Override + void fillStringMapLocal(Map map) { + HashSupport.fillStringMap(this.buckets, map); + } + + @Override + void checkIntegrity() { + HashSupport.checkBuckets(this.buckets); + if (this.count != this.computeSize()) { + throw new IllegalStateException("incorrect size"); + } + } + + @Override + int computeSize() { + return HashSupport.count(this.buckets); + } + + @Override + String toInternalString() { + return HashSupport.toInternalString(this.buckets); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/HashStore.java b/internal-api/src/main/java/datadog/trace/api/HashStore.java new file mode 100644 index 00000000000..9ea6baf8d5f --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashStore.java @@ -0,0 +1,366 @@ +package datadog.trace.api; + +import datadog.apicontract.Restricted; +import datadog.trace.api.TagMap.BucketGroup; +import datadog.trace.api.TagMap.Entry; +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * The custom-tag half of a {@link TagMap}: custom tags by name, in their own bucket array ({@link + * HashSupport}), reading through to the frozen parent's custom store. Name-keyed methods take only + * custom tag names (asserted): a known tag is resolved to its id first, and goes to the {@link + * IdStore}. + * + *

    Values move by {@code store}, as through {@link TagStore}; entries by {@code getEntry}, {@code + * put} and {@code remove}. Unlike {@link TagStore}, writes take no nulls: the owner turns a null + * value into {@code remove} and skips a null entry before calling. + * + *

    Created lazily by its owner -- a {@link TagMap}, or a span in the tracer core -- on the first + * custom write ({@link #create}); until then the owner borrows its parent's custom store, if any, + * read-only. No locking: guarded by the owner's lock. + */ +@Restricted(allowedIn = {"datadog.trace.core"}) +public final class HashStore { + final Object[] buckets; + + /** Local entries. */ + int count; + + /** The frozen parent's custom store, or null. */ + final HashStore parent; + + /** + * Parent keys removed locally (read-through tombstones): a tombstone stops read-through for its + * key. Allocated on the first such removal, so while it is {@code null} the hot paths skip the + * tombstone checks. + */ + private Set removedFromParent; + + /** + * A fresh custom store for an owner's first custom write, reading through to {@code borrowed}, + * the store it borrowed until now (may be null). + */ + public static HashStore create(HashStore borrowed) { + return new HashStore(borrowed); + } + + HashStore(HashStore parent) { + this.buckets = HashSupport.newBuckets(); + this.parent = parent; + } + + /** Exact count, including the visible part of the read-through chain. */ + int size() { + return this.parent == null ? this.count : this.count + this.visibleParentCount(); + } + + /** Ancestor entries not shadowed or tombstoned by a nearer level, nearest-level-wins. */ + private int visibleParentCount() { + // a plain loop, not forEachParent: counting through a lambda would allocate a counter + int count = 0; + for (HashStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object bucket = parentBuckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (this.parentEntryVisible(entry, ancestor)) { + count++; + } + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && this.parentEntryVisible(entry, ancestor)) { + count++; + } + } + } + } + } + } + return count; + } + + boolean isEmpty() { + if (this.count != 0) { + return false; + } + HashStore parent = this.parent; + if (parent == null) { + return true; + } + if (this.removedFromParent == null) { + return parent.isEmpty(); // nothing hidden: the parent's tags are all visible + } + return this.visibleParentCount() == 0; + } + + /** Cheap: no level of the chain holds a local entry (ignores shadowing and tombstones). */ + boolean isDefinitelyEmpty() { + for (HashStore level = this; level != null; level = level.parent) { + if (level.count != 0) { + return false; + } + } + return true; + } + + /** Upper bound: the local counts of every level. */ + int estimateSize() { + int total = 0; + for (HashStore level = this; level != null; level = level.parent) { + total += level.count; + } + return total; + } + + /** The custom tag {@code tag}'s entry, reading through to the parent chain, or null. */ + @Nullable + public Entry getEntry(String tag) { + assert KnownTagCodec.keyOf(tag) == 0 : "'" + tag + "' is a known tag; look it up by its id"; + return this.get(tag, Entry.bucketHash(Entry.customHash(tag))); + } + + private Entry get(String tag, int hash) { + Entry local = HashSupport.find(this.buckets, hash, tag); + if (local != null) { + return local; + } + HashStore parent = this.parent; + if (parent == null) { + return null; + } + if (this.removedFromParent != null && this.removedFromParent.contains(tag)) { + return null; // tombstoned: removed locally, do not read through + } + return parent.get(tag, hash); + } + + /* + * Stores the custom tag {@code tag}, which must not be a known tag's name. + */ + + public void store(String tag, @Nonnull Object value) { + this.put(Entry.newAnyEntry(tag, value)); + } + + public void store(String tag, @Nonnull CharSequence value) { + this.put(Entry.newObjectEntry(tag, value)); + } + + public void store(String tag, boolean value) { + this.put(Entry.newBooleanEntry(tag, value)); + } + + public void store(String tag, int value) { + this.put(Entry.newIntEntry(tag, value)); + } + + public void store(String tag, long value) { + this.put(Entry.newLongEntry(tag, value)); + } + + public void store(String tag, float value) { + this.put(Entry.newFloatEntry(tag, value)); + } + + public void store(String tag, double value) { + this.put(Entry.newDoubleEntry(tag, value)); + } + + /** + * Inserts or replaces a custom tag's entry, returning the local entry it replaced (or null). Does + * not consult the read-through parent. + */ + @Nullable + public Entry put(@Nonnull Entry newEntry) { + // Re-setting a key clears any read-through tombstone for it (the new value overrides the + // removal). Gated on the lazy field, so this is a no-op for the common no-tombstone case. + if (this.removedFromParent != null) { + this.removedFromParent.remove(newEntry.tag); + } + Entry replaced = HashSupport.put(this.buckets, newEntry); + if (replaced == null) { + this.count++; + } + return replaced; + } + + /** {@link #put}, returning the prior visible entry -- local, else read through. */ + @Nullable + public Entry getAndPut(@Nonnull Entry newEntry) { + // Capture whether the key was tombstoned BEFORE put clears it: a tombstoned key had no visible + // prior value (it was removed), so getAndPut must report null rather than the parent's. + boolean wasTombstoned = + this.removedFromParent != null && this.removedFromParent.contains(newEntry.tag); + + Entry priorLocal = this.put(newEntry); + if (priorLocal != null) { + return priorLocal; + } + if (wasTombstoned || this.parent == null) { + return null; + } + return this.parent.get(newEntry.tag, newEntry.hash()); + } + + /** + * Removes the custom tag {@code tag}, returning the prior visible entry. Removing a key the + * parent chain still exposes also installs a tombstone, so it stops reading through. + */ + @Nullable + public Entry remove(String tag) { + assert KnownTagCodec.keyOf(tag) == 0 : "'" + tag + "' is a known tag; remove it by its id"; + int hash = Entry.bucketHash(Entry.customHash(tag)); + Entry localRemoved = HashSupport.remove(this.buckets, hash, tag); + if (localRemoved != null) { + this.count--; + } + + HashStore parent = this.parent; + if (parent != null) { + boolean alreadyTombstoned = + this.removedFromParent != null && this.removedFromParent.contains(tag); + if (!alreadyTombstoned) { + Entry parentEntry = parent.get(tag, hash); + if (parentEntry != null) { + if (this.removedFromParent == null) { + // Small initial capacity: this set is rare and almost always holds only a handful of + // tombstoned keys, so the default 16-bucket HashSet table would be wasteful. + this.removedFromParent = new HashSet<>(4); + } + this.removedFromParent.add(tag); + return localRemoved != null ? localRemoved : parentEntry; + } + } + } + return localRemoved; + } + + /** + * Whether {@code parentEntry}, from the ancestor level {@code fromAncestor}, is visible through + * this leaf: not shadowed and not tombstoned by any nearer level. + */ + boolean parentEntryVisible(Entry parentEntry, HashStore fromAncestor) { + int hash = parentEntry.hash(); + String tag = parentEntry.tag; + for (HashStore nearer = this; nearer != fromAncestor; nearer = nearer.parent) { + if (nearer.removedFromParent != null && nearer.removedFromParent.contains(tag)) { + return false; + } + if (HashSupport.find(nearer.buckets, hash, tag) != null) { + return false; + } + } + return true; + } + + /** Visits the local entries, then the visible inherited ones. */ + void forEach(Consumer consumer) { + HashSupport.forEach(this.buckets, consumer); + if (this.parent != null) { + this.forEachParent(consumer, null, (c, unused, entry) -> c.accept(entry)); + } + } + + void forEach(T thisObj, BiConsumer consumer) { + HashSupport.forEach(this.buckets, thisObj, consumer); + if (this.parent != null) { + this.forEachParent(thisObj, consumer, (t, c, entry) -> c.accept(t, entry)); + } + } + + void forEach(T thisObj, U otherObj, TriConsumer consumer) { + HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); + if (this.parent != null) { + this.forEachParent(thisObj, otherObj, consumer); + } + } + + private void forEachParent( + T thisObj, U otherObj, TriConsumer consumer) { + for (HashStore ancestor = this.parent; ancestor != null; ancestor = ancestor.parent) { + Object[] parentBuckets = ancestor.buckets; + for (int i = 0; i < parentBuckets.length; ++i) { + Object bucket = parentBuckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (this.parentEntryVisible(entry, ancestor)) { + consumer.accept(thisObj, otherObj, entry); + } + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && this.parentEntryVisible(entry, ancestor)) { + consumer.accept(thisObj, otherObj, entry); + } + } + } + } + } + } + } + + /** + * An independently mutable copy: the same (frozen, shared) parent, this store's own entries + * (shared Entry objects) and its tombstones. + */ + HashStore copy() { + HashStore copy = new HashStore(this.parent); + HashSupport.copyInto(copy.buckets, this.buckets); + copy.count = this.count; + if (this.removedFromParent != null) { + copy.removedFromParent = new HashSet<>(this.removedFromParent); + } + return copy; + } + + /** Copies the entries of the parentless store {@code that}, sharing Entry objects. */ + void putAllLocal(HashStore that) { + if (that.count == 0) { + return; + } + if (this.count == 0) { + HashSupport.copyInto(this.buckets, that.buckets); + this.count = that.count; + } else { + this.count += HashSupport.merge(this.buckets, that.buckets); + } + } + + void fillMapLocal(Map map) { + HashSupport.fillMap(this.buckets, map); + } + + void fillStringMapLocal(Map map) { + HashSupport.fillStringMap(this.buckets, map); + } + + /** Tests only: throws if the store's structure is inconsistent. */ + void checkIntegrity() { + HashSupport.checkBuckets(this.buckets); + if (this.count != this.computeSize()) { + throw new IllegalStateException("incorrect size"); + } + } + + /** Tests only: the local count, recomputed from the structure. */ + int computeSize() { + return HashSupport.count(this.buckets); + } + + /** For deep debugging. */ + String toInternalString() { + return HashSupport.toInternalString(this.buckets); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/HashSupport.java b/internal-api/src/main/java/datadog/trace/api/HashSupport.java new file mode 100644 index 00000000000..08204326ab5 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/HashSupport.java @@ -0,0 +1,533 @@ +package datadog.trace.api; + +import datadog.trace.api.TagMap.BucketGroup; +import datadog.trace.api.TagMap.Entry; +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * The raw bucket mechanics both tag stores share, over one fixed-size {@code Object[]} of buckets + * of shareable, immutable {@link Entry} objects. A bucket holds a lone Entry directly; because + * Entries are shared between maps, they cannot form a linked list, so colliding entries share a + * fixed-size {@link BucketGroup}, chained when one fills. Removals are assumed rare, so groups are + * never consolidated, but an emptied group is unlinked from its chain. + * + *

    Stateless: each operation reports what it did through its return value -- put returns the + * entry it replaced, remove the entry it removed -- and the calling store keeps its own count. + * Copies share Entries and clone only BucketGroup chains. Iteration skips tombstones ({@link + * Entry#tombstone}), which only an id store holds. + */ +final class HashSupport { + private HashSupport() {} + + /** A fresh bucket array; its length must be a power of 2 for bucket masking. */ + static Object[] newBuckets() { + return new Object[1 << 4]; + } + + /** The entry for {@code (hash, tag)}, tombstone or not, or null. */ + static Entry find(Object[] buckets, int hash, String tag) { + return findInBucket(buckets[hash & (buckets.length - 1)], hash, tag); + } + + /** + * {@link #find} for a known tag, matching on its id rather than its name, so an id store never + * needs a name. {@code hash} is {@code Entry.bucketHash(tagId)}. + */ + static Entry findById(Object[] buckets, int hash, long tagId) { + Object bucket = buckets[hash & (buckets.length - 1)]; + if (bucket instanceof Entry) { + Entry tagEntry = (Entry) bucket; + return tagEntry.tagHash == tagId ? tagEntry : null; + } else if (bucket instanceof BucketGroup) { + return ((BucketGroup) bucket).findInChainById(hash, tagId); + } + return null; + } + + /** Finds an entry by hash and tag in one bucket: an Entry, a BucketGroup chain, or null. */ + private static Entry findInBucket(Object bucket, int hash, String tag) { + if (bucket instanceof Entry) { + Entry tagEntry = (Entry) bucket; + return tagEntry.hash() == hash && tagEntry.matches(tag) ? tagEntry : null; + } else if (bucket instanceof BucketGroup) { + return ((BucketGroup) bucket).findInChain(hash, tag); + } + return null; + } + + /** Inserts or replaces {@code newEntry}, returning the entry it replaced, or null if it added. */ + static Entry put(Object[] buckets, Entry newEntry) { + Object[] thisBuckets = buckets; + + int newHash = newEntry.hash(); + int bucketIndex = newHash & (thisBuckets.length - 1); + + Object bucket = thisBuckets[bucketIndex]; + if (bucket == null) { + thisBuckets[bucketIndex] = newEntry; + + return null; + } else if (bucket instanceof Entry) { + Entry existingEntry = (Entry) bucket; + if (existingEntry.hash() == newHash && existingEntry.matches(newEntry.tag)) { + thisBuckets[bucketIndex] = newEntry; + + // replaced existing entry - no size change + return existingEntry; + } else { + thisBuckets[bucketIndex] = + new BucketGroup(existingEntry.hash(), existingEntry, newHash, newEntry); + + return null; + } + } else if (bucket instanceof BucketGroup) { + BucketGroup lastGroup = (BucketGroup) bucket; + + BucketGroup containingGroup = lastGroup.findContainingGroupInChain(newHash, newEntry.tag); + if (containingGroup != null) { + // replaced existing entry - no size change + return containingGroup._replace(newHash, newEntry); + } + + if (!lastGroup.insertInChain(newHash, newEntry)) { + thisBuckets[bucketIndex] = new BucketGroup(newHash, newEntry, lastGroup); + } + return null; + } + + // unreachable + return null; + } + + /** Removes the entry for {@code (hash, tag)}, returning it, or null if there was none. */ + static Entry remove(Object[] buckets, int hash, String tag) { + Object[] thisBuckets = buckets; + int bucketIndex = hash & (thisBuckets.length - 1); + + Object bucket = thisBuckets[bucketIndex]; + // null bucket case - do nothing + if (bucket instanceof Entry) { + Entry existingEntry = (Entry) bucket; + if (existingEntry.hash() == hash && existingEntry.matches(tag)) { + thisBuckets[bucketIndex] = null; + + return existingEntry; + } else { + return null; + } + } else if (bucket instanceof BucketGroup) { + BucketGroup lastGroup = (BucketGroup) bucket; + + BucketGroup containingGroup = lastGroup.findContainingGroupInChain(hash, tag); + if (containingGroup == null) { + return null; + } + + Entry existingEntry = containingGroup._remove(hash, tag); + if (containingGroup._isEmpty()) { + thisBuckets[bucketIndex] = lastGroup.removeGroupInChain(containingGroup); + } + + return existingEntry; + } + return null; + } + + /** + * Copies {@code thatBuckets} into the empty {@code thisBuckets}, sharing Entry objects and + * cloning only BucketGroup chains. + */ + static void copyInto(Object[] thisBuckets, Object[] thatBuckets) { + + // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check + // elimination + for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { + Object thatBucket = thatBuckets[i]; + + // faster to explicitly null check first, then do instanceof + if (thatBucket == null) { + // do nothing + } else if (thatBucket instanceof BucketGroup) { + // if it is a BucketGroup, then need to clone + BucketGroup thatGroup = (BucketGroup) thatBucket; + + thisBuckets[i] = thatGroup.cloneChain(); + } else { // if ( thatBucket instanceof Entry ) + thisBuckets[i] = thatBucket; + } + } + } + + /** + * Merges {@code thatBuckets} into {@code thisBuckets}, sharing Entry objects and cloning only + * BucketGroup chains; incoming entries replace existing ones. Returns how many it added. Both + * arrays must be the same length (stores don't expand, so buckets are perfectly aligned). + */ + static int merge(Object[] thisBuckets, Object[] thatBuckets) { + int added = 0; + + // Stores don't expand, so buckets are perfectly aligned + // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check + // elimination + for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { + Object thatBucket = thatBuckets[i]; + + // if nothing incoming, nothing to do + if (thatBucket == null) continue; + + Object thisBucket = thisBuckets[i]; + if (thisBucket == null) { + // This bucket is null, easy case + // Either copy over the sole entry or clone the BucketGroup chain + + if (thatBucket instanceof Entry) { + thisBuckets[i] = thatBucket; + added += 1; + } else if (thatBucket instanceof BucketGroup) { + BucketGroup thatGroup = (BucketGroup) thatBucket; + + BucketGroup thisNewGroup = thatGroup.cloneChain(); + thisBuckets[i] = thisNewGroup; + added += thisNewGroup.sizeInChain(); + } + } else if (thisBucket instanceof Entry) { + // This bucket is a single entry, medium complexity case + // If other side is an Entry - just merge the entries into a bucket + // If other side is a BucketGroup - then clone the group and insert the entry normally into + // the cloned group + + Entry thisEntry = (Entry) thisBucket; + int thisHash = thisEntry.hash(); + + if (thatBucket instanceof Entry) { + Entry thatEntry = (Entry) thatBucket; + int thatHash = thatEntry.hash(); + + if (thisHash == thatHash && thisEntry.matches(thatEntry.tag())) { + thisBuckets[i] = thatEntry; + // replacing entry, no size change + } else { + thisBuckets[i] = + new BucketGroup( + thisHash, thisEntry, + thatHash, thatEntry); + added += 1; + } + } else if (thatBucket instanceof BucketGroup) { + BucketGroup thatGroup = (BucketGroup) thatBucket; + + // Clone the other group, then place this entry into that group + BucketGroup thisNewGroup = thatGroup.cloneChain(); + int thisNewGroupSize = thisNewGroup.sizeInChain(); + + Entry incomingEntry = thisNewGroup.findInChain(thisHash, thisEntry.tag()); + if (incomingEntry != null) { + // there's already an entry w/ the same tag from the incoming TagMap + // incoming entry clobbers the existing entry, so we're done + thisBuckets[i] = thisNewGroup; + + // overlapping group - subtract one for clobbered existing entry + added += thisNewGroupSize - 1; + } else if (thisNewGroup.insertInChain(thisHash, thisEntry)) { + // able to add thisEntry into the existing groups + thisBuckets[i] = thisNewGroup; + + // non overlapping group - existing entry already counted + added += thisNewGroupSize; + } else { + // unable to add into the existing groups + thisBuckets[i] = new BucketGroup(thisHash, thisEntry, thisNewGroup); + + // non overlapping group - existing entry already counted + added += thisNewGroupSize; + } + } + } else if (thisBucket instanceof BucketGroup) { + // This bucket is a BucketGroup, medium to hard case + // If the other side is an entry, just normal insertion procedure - no cloning required + BucketGroup thisGroup = (BucketGroup) thisBucket; + + if (thatBucket instanceof Entry) { + Entry thatEntry = (Entry) thatBucket; + int thatHash = thatEntry.hash(); + + if (thisGroup.replaceInChain(thatHash, thatEntry) != null) { + // replaced existing entry no size change + } else if (thisGroup.insertInChain(thatHash, thatEntry)) { + added += 1; + } else { + thisBuckets[i] = new BucketGroup(thatHash, thatEntry, thisGroup); + added += 1; + } + } else if (thatBucket instanceof BucketGroup) { + // Most complicated case - need to walk that bucket group chain and update this chain + BucketGroup thatGroup = (BucketGroup) thatBucket; + + // Taking the easy / expensive way out for updating size + int thisPrevGroupSize = thisGroup.sizeInChain(); + + BucketGroup thisNewGroup = thisGroup.replaceOrInsertAllInChain(thatGroup); + int thisNewGroupSize = thisNewGroup.sizeInChain(); + + thisBuckets[i] = thisNewGroup; + added += (thisNewGroupSize - thisPrevGroupSize); + } + } + } + return added; + } + + /** + * Visits every entry in {@code buckets}, which must hold no tombstones, as a {@link TagMap}'s and + * a {@link HashStore}'s never do. {@link #forEachLive} skips them. This walk runs for every span + * the serializer writes, so it stays the unrolled shape with no tombstone check. + */ + static void forEach(Object[] buckets, Consumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + consumer.accept((Entry) bucket); + } else if (bucket instanceof BucketGroup) { + ((BucketGroup) bucket).forEachInChain(consumer); + } + } + } + + /** {@link #forEach(Object[], Consumer)} with a context object for a non-capturing consumer. */ + static void forEach( + Object[] buckets, T thisObj, BiConsumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + consumer.accept(thisObj, (Entry) bucket); + } else if (bucket instanceof BucketGroup) { + ((BucketGroup) bucket).forEachInChain(thisObj, consumer); + } + } + } + + /** {@link #forEach(Object[], Consumer)} with two context objects. */ + static void forEach( + Object[] buckets, T thisObj, U otherObj, TriConsumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + consumer.accept(thisObj, otherObj, (Entry) bucket); + } else if (bucket instanceof BucketGroup) { + ((BucketGroup) bucket).forEachInChain(thisObj, otherObj, consumer); + } + } + } + + /** + * Visits every live entry in {@code buckets}, skipping tombstones, which only an id store holds. + */ + static void forEachLive(Object[] buckets, Consumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone) consumer.accept(entry); + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone) consumer.accept(entry); + } + } + } + } + } + + /** {@link #forEachLive(Object[], Consumer)} with a context object. */ + static void forEachLive( + Object[] buckets, T thisObj, BiConsumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone) consumer.accept(thisObj, entry); + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone) consumer.accept(thisObj, entry); + } + } + } + } + } + + /** {@link #forEachLive(Object[], Consumer)} with two context objects. */ + static void forEachLive( + Object[] buckets, T thisObj, U otherObj, TriConsumer consumer) { + for (int i = 0; i < buckets.length; ++i) { + Object bucket = buckets[i]; + if (bucket instanceof Entry) { + Entry entry = (Entry) bucket; + if (!entry.tombstone) consumer.accept(thisObj, otherObj, entry); + } else if (bucket instanceof BucketGroup) { + for (BucketGroup group = (BucketGroup) bucket; group != null; group = group.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry entry = group._entryAt(j); + if (entry != null && !entry.tombstone) consumer.accept(thisObj, otherObj, entry); + } + } + } + } + } + + /** Puts every live entry in {@code buckets} into {@code map}, by tag, as its object value. */ + static void fillMap(Object[] buckets, Map map) { + forEachLive(buckets, map, (m, entry) -> m.put(entry.tag(), entry.objectValue())); + } + + /** Puts every live entry in {@code buckets} into {@code map}, by tag, as its string value. */ + static void fillStringMap(Object[] buckets, Map map) { + forEachLive(buckets, map, (m, entry) -> m.put(entry.tag(), entry.stringValue())); + } + + /** The number of live entries, not counting tombstones. Tests only. */ + static int count(Object[] buckets) { + int[] count = new int[1]; + forEachLive(buckets, count, (c, entry) -> c[0]++); + return count[0]; + } + + /** Throws if an entry sits in the wrong bucket. Tests only. */ + static void checkBuckets(Object[] buckets) { + // Decided to use if ( cond ) throw new IllegalStateException rather than assert + // That was done to avoid the extra static initialization needed for an assertion + // While that's probably an unnecessary optimization, this method is only called in tests + + Object[] thisBuckets = buckets; + + for (int i = 0; i < thisBuckets.length; ++i) { + Object thisBucket = thisBuckets[i]; + + if (thisBucket instanceof Entry) { + Entry thisEntry = (Entry) thisBucket; + int thisHash = thisEntry.hash(); + + int expectedBucket = thisHash & (thisBuckets.length - 1); + if (expectedBucket != i) { + throw new IllegalStateException("incorrect bucket"); + } + } else if (thisBucket instanceof BucketGroup) { + BucketGroup thisGroup = (BucketGroup) thisBucket; + + for (BucketGroup curGroup = thisGroup; curGroup != null; curGroup = curGroup.prev) { + for (int j = 0; j < BucketGroup.LEN; ++j) { + Entry thisEntry = curGroup._entryAt(j); + if (thisEntry == null) continue; + + int thisHash = thisEntry.hash(); + assert curGroup._hashAt(j) == thisHash; + + int expectedBucket = thisHash & (thisBuckets.length - 1); + if (expectedBucket != i) { + throw new IllegalStateException("incorrect bucket"); + } + } + } + } + } + } + + /** The buckets' internal structure, for deep debugging. */ + static String toInternalString(Object[] buckets) { + Object[] thisBuckets = buckets; + + StringBuilder ledger = new StringBuilder(128); + for (int i = 0; i < thisBuckets.length; ++i) { + ledger.append('[').append(i).append("] = "); + + Object thisBucket = thisBuckets[i]; + if (thisBucket == null) { + ledger.append("null"); + } else if (thisBucket instanceof Entry) { + ledger.append('{').append(thisBucket).append('}'); + } else if (thisBucket instanceof BucketGroup) { + for (BucketGroup curGroup = (BucketGroup) thisBucket; + curGroup != null; + curGroup = curGroup.prev) { + ledger.append(curGroup).append(" -> "); + } + } + ledger.append('\n'); + } + return ledger.toString(); + } + + /** Whether {@code buckets} holds no entry, checked bucket by bucket. */ + static boolean isEmpty(Object[] buckets) { + Object[] thisBuckets = buckets; + + for (int i = 0; i < thisBuckets.length; ++i) { + Object curBucket = thisBuckets[i]; + + if (curBucket instanceof Entry) { + return false; + } else if (curBucket instanceof BucketGroup) { + BucketGroup curGroup = (BucketGroup) curBucket; + if (!curGroup.isEmptyChain()) return false; + } + } + + return true; + } + + /** Walks a bucket array's raw entries, tombstones included, for an iterator. */ + static class BucketWalker { + private Object[] buckets; + private int bucketIndex; + private BucketGroup group; + private int groupIndex; + + /** Starts over on {@code buckets}. */ + void reset(Object[] buckets) { + this.buckets = buckets; + this.bucketIndex = -1; + this.group = null; + this.groupIndex = 0; + } + + /** The next raw entry, or null when the array is done. */ + final Entry nextEntry() { + while (this.bucketIndex < this.buckets.length) { + if (this.group != null) { + for (++this.groupIndex; this.groupIndex < BucketGroup.LEN; ++this.groupIndex) { + Entry tagEntry = this.group._entryAt(this.groupIndex); + if (tagEntry != null) return tagEntry; + } + + // done processing - that group, go to next group + this.group = this.group.prev; + this.groupIndex = -1; + } + + // if the group is null, then we've finished the current bucket - so advance the bucket + if (this.group == null) { + for (++this.bucketIndex; this.bucketIndex < this.buckets.length; ++this.bucketIndex) { + Object bucket = this.buckets[this.bucketIndex]; + + if (bucket instanceof Entry) { + return (Entry) bucket; + } else if (bucket instanceof BucketGroup) { + this.group = (BucketGroup) bucket; + this.groupIndex = -1; + + break; + } + } + } + } + + return null; + } + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/IdStore.java b/internal-api/src/main/java/datadog/trace/api/IdStore.java new file mode 100644 index 00000000000..79e181ed426 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/IdStore.java @@ -0,0 +1,173 @@ +package datadog.trace.api; + +import datadog.apicontract.Restricted; +import datadog.trace.api.TagMap.EntryReader; +import datadog.trace.api.function.TriConsumer; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Known tags keyed by their {@link KnownTags} id. Every method takes an id, or an {@link + * EntryReader} for a known tag -- never a name, so nothing here can be handed a name that a caller + * has not yet resolved. + * + *

    A span in the tracer core owns its id store directly ({@link IdStoreFactory#create}); a {@link + * TagMap} owns one too. + * + *

    Values move by {@code load} and {@code store}, as through {@link TagStore}; entries by {@code + * getEntry}, {@code put} and {@code remove}. Unlike {@link TagStore}, writes take no nulls: the + * owner turns a null value into {@code remove} and skips a null entry before calling. + * + *

    {@link IdStoreFactory} builds every store, so the JVM loads one implementation and class + * hierarchy analysis devirtualizes every call on an {@code IdStore} without a type guard; nothing + * that is always loaded may extend this class. No locking: guarded by the owner's lock. + */ +@Restricted(allowedIn = {"datadog.trace.core"}) +public abstract class IdStore { + /** Visits a store's visible entries one at a time. */ + interface Cursor { + /** The next visible entry, or null when there are none left. */ + @Nullable + EntryReader next(); + } + + /** Throws for an id that names no known tag. */ + static IllegalArgumentException notKnown(long tagId) { + return new IllegalArgumentException("not a known tag id: " + Long.toHexString(tagId)); + } + + /* + * Point operations. A returned EntryReader is valid only until this store's next operation: a + * store that keeps known tags without an Entry per tag answers with a reused flyweight. Call + * EntryReader.entry() to keep one. Stores are inside the tracer's trust boundary, so callers are + * expected to follow this rather than have it enforced. + */ + + /** + * The entry for the known tag {@code tagId}, reading through to the parent chain, or null. + * + * @throws IllegalArgumentException if {@code tagId} names no known tag + */ + @Nullable + public abstract EntryReader getEntryReader(long tagId); + + /** + * The entry for the known tag {@code tagId}, reading through to the parent chain, or null. Unlike + * {@link #getEntryReader}, the result is an {@link TagMap.Entry} the caller may keep, and it + * shares no state with the store, so it is safe to call concurrently on a frozen store that + * several threads read, such as a span prototype's or the tracer-level tags. + * + * @throws IllegalArgumentException if {@code tagId} names no known tag + */ + @Nullable + public abstract TagMap.Entry getEntry(long tagId); + + /** + * {@link #getEntryReader}'s value, boxed if necessary, or null -- for a caller that needs only + * the value, so a store that keeps known tags without an Entry per tag can answer without one. + */ + @Nullable + public abstract Object load(long tagId); + + /** + * {@link #load} as a String, or null. A store that keeps an Entry per tag answers with the + * Entry's cached string, so a numeric tag read repeatedly, as the serializer does, is formatted + * once. + */ + @Nullable + public abstract String loadString(long tagId); + + /* + * Stores a known tag without validating its id: the caller has checked KnownTagCodec.isKnown. + */ + + public abstract void store(long tagId, @Nonnull Object value); + + public abstract void store(long tagId, @Nonnull CharSequence value); + + public abstract void store(long tagId, boolean value); + + public abstract void store(long tagId, int value); + + public abstract void store(long tagId, long value); + + public abstract void store(long tagId, float value); + + public abstract void store(long tagId, double value); + + /** + * Inserts or replaces a known tag, returning the local entry it replaced (or null). Does not + * consult the read-through parent. A store that keeps Entry objects keeps {@code entryReader}'s + * Entry (free when it is one); a store that doesn't copies its value. + */ + @Nullable + public abstract EntryReader put(@Nonnull EntryReader entryReader); + + /** {@link #put}, returning the prior visible entry -- local, else read through. */ + @Nullable + public abstract EntryReader getAndPut(@Nonnull EntryReader entryReader); + + /** + * Removes the known tag {@code tagId}, returning the prior visible entry; an inherited one is + * hidden by a tombstone. + * + * @throws IllegalArgumentException if {@code tagId} names no known tag + */ + @Nullable + public abstract EntryReader remove(long tagId); + + /* + * Whole-store operations, for the owning TagMap. + */ + + /** Exact count, including the visible part of the read-through chain. */ + abstract int size(); + + abstract boolean isEmpty(); + + /** Cheap: no level of the chain holds a local entry (ignores shadowing and tombstones). */ + abstract boolean isDefinitelyEmpty(); + + /** Upper bound: the local counts of every level. */ + abstract int estimateSize(); + + /** Whether this store reads through to a parent. */ + abstract boolean hasParent(); + + /** Visits the local entries, then the visible inherited ones. */ + abstract void forEach(Consumer consumer); + + abstract void forEach(T thisObj, BiConsumer consumer); + + abstract void forEach( + T thisObj, U otherObj, TriConsumer consumer); + + /** + * A cursor over the visible entries, local then inherited, for an iterator. Each entry it returns + * is valid until its next call, as with a point read. + */ + abstract Cursor cursor(); + + /** An independently mutable copy with the same parent, sharing any Entry objects it holds. */ + abstract IdStore copy(); + + /** Copies the entries of the parentless store {@code that}, sharing any Entry objects. */ + abstract void putAllLocal(IdStore that); + + /** Puts the local entries into {@code map}, for a store with no parent. */ + abstract void fillMapLocal(Map map); + + abstract void fillStringMapLocal(Map map); + + /** Tests only: throws if the store's structure is inconsistent. */ + abstract void checkIntegrity(); + + /** Tests only: the local count, recomputed from the structure. */ + abstract int computeSize(); + + /** For deep debugging. */ + abstract String toInternalString(); +} diff --git a/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java new file mode 100644 index 00000000000..ef680ba4146 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/IdStoreFactory.java @@ -0,0 +1,42 @@ +package datadog.trace.api; + +/** + * Builds the {@link IdStore}s of the one implementation this JVM uses, today {@link HashIdStore}. + * + *

    Every store is built through {@link Default#INSTANCE}, never with {@code new}, so a JVM loads + * one {@code IdStore} subclass and class hierarchy analysis devirtualizes every call on an {@code + * IdStore} without a guard. A store builds its own copies. + * + *

    No static state here, and no {@link TagMap}, so {@link TagMap#EMPTY} can be built with {@link + * #createEmpty} whichever of the two classes initializes first. + */ +abstract class IdStoreFactory { + /** The JVM's store. */ + static final class Default { + static final IdStoreFactory INSTANCE = HashIdStoreFactory.INSTANCE; + + private Default() {} + } + + /** A fresh store reading through to {@code parent} (may be null). */ + abstract IdStore create(IdStore parent); + + /** A new empty parentless store, for a frozen map. */ + abstract IdStore createEmpty(); +} + +final class HashIdStoreFactory extends IdStoreFactory { + static final HashIdStoreFactory INSTANCE = new HashIdStoreFactory(); + + private HashIdStoreFactory() {} + + @Override + IdStore create(IdStore parent) { + return new HashIdStore(parent); + } + + @Override + IdStore createEmpty() { + return HashIdStore.createEmpty(); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/TagMap.java b/internal-api/src/main/java/datadog/trace/api/TagMap.java index be07fadbeeb..ea9354a4589 100644 --- a/internal-api/src/main/java/datadog/trace/api/TagMap.java +++ b/internal-api/src/main/java/datadog/trace/api/TagMap.java @@ -48,7 +48,7 @@ *

  • adaptive collision * */ -public final class TagMap implements Map, Iterable { +public final class TagMap implements Map, Iterable, TagStore { /** Immutable empty TagMap - similar to {@link Collections#emptyMap()} */ // Frozen view over a length-1 array: bucket masking needs a power-of-two array length (size 0 // would fail with ArrayIndexOutOfBoundsException, size 1 works), and the private constructor @@ -158,6 +158,13 @@ public boolean isRemoval() { } } + /** + * A momentary view of one tag: read it where it was obtained and drop it. A reader MUST NOT be + * kept past the call that produced it -- not in a field, a collection or a later step. A store + * may answer with a flyweight it reuses for the next read or write, so a kept reader can later + * report another tag's value. To keep a tag, call {@link #entry()}: an {@link Entry} is the + * keepable form. (To be marked {@code @ScopeBounded}, APMLP-2086.) + */ public interface EntryReader { public static final byte OBJECT = 1; @@ -447,6 +454,41 @@ static Entry newDoubleEntry(String tag, Double box) { volatile String strCache = null; + /** + * True for a known tag's tombstone: the marker the hash id store puts in its own buckets to + * hide an inherited entry it has removed. Never visible outside the store; see {@link + * #tombstoneOf}. + */ + final boolean tombstone; + + /** The shared tombstone per known tag, by serial, created on first use. */ + private static final Entry[] TOMBSTONES = new Entry[KnownTagCodec.serialLimit()]; + + /** + * The tombstone for the known tag {@code tagId}: one shared, immutable entry per tag, so + * tombstoning a known tag allocates nothing after the first time. Racing first uses may each + * build one; either serves, since a tombstone is told apart by {@link #tombstone}, not + * identity. + */ + static Entry tombstoneOf(long tagId) { + int serial = KnownTagCodec.serialNum(tagId); + Entry tombstone = TOMBSTONES[serial]; + if (tombstone == null || tombstone.tagHash != tagId) { + tombstone = new Entry(tagId); + TOMBSTONES[serial] = tombstone; + } + return tombstone; + } + + private Entry(long tagId) { + super(requireKnownName(tagId)); + this.tagHash = tagId; + this.tombstone = true; + this.rawType = OBJECT; + this.rawPrim = 0L; + this.rawObj = null; + } + /** * A custom tag's entry: no registry lookup. A known tag is built from its id instead -- callers * that start from a name resolve it once and pick the id or name factory (see {@code @@ -461,6 +503,7 @@ private Entry(String tag, byte type, long prim, Object obj) { // Resolve the name once: it both validates the id and names the entry. super(requireKnownName(tagId)); this.tagHash = tagId; + this.tombstone = false; this.rawType = type; this.rawPrim = prim; @@ -471,6 +514,7 @@ private Entry(String tag, byte type, long prim, Object obj) { Entry(long tagId, String customTag, byte type, long prim, Object obj) { super(tagId != 0 ? KnownTagCodec.nameOf(tagId) : customTag); this.tagHash = tagId != 0 ? tagId : customHash(customTag); + this.tombstone = false; this.rawType = type; this.rawPrim = prim; @@ -497,14 +541,27 @@ static long customHash(String tag) { } /** - * Folds a tag hash to the 32-bit bucket hash. A tag id's serial is in bits 63-48, so it is - * folded down from there rather than from bit 32, where a bucket mask would discard it. + * Folds a tag hash to the 32-bit bucket hash, whose low bits pick the bucket. * - *

    Never zero: BucketGroup treats a zero hash as a vacant slot. The fold can produce zero - * when a serial equals the id's flag bits (serial 4 with the trace-level bit, 4), so zero maps - * to the same nonzero sentinel {@link #_hash} uses. + *

    A known tag with a slot is bucketed by its slot: slot coloring gives tags that appear + * together on a span type distinct slots, so they mostly land in distinct buckets and need no + * BucketGroup. Trace-level tags reuse the span-level slot numbers, so their level bit moves + * them to the other half of the buckets. The serial sits above the slot, so two known tags + * never share a bucket hash. + * + *

    A known tag without a slot folds its serial (bits 63-48) down instead, and a custom tag + * uses its name hash. + * + *

    Never zero: BucketGroup treats a zero hash as a vacant slot. A slotted known tag's hash + * carries its nonzero serial; the other folds map zero to the same nonzero sentinel {@link + * #_hash} uses. */ static int bucketHash(long tagHash) { + int slot = (int) (tagHash >>> 32) & 0xFFFF; + if ((tagHash >>> 32) != 0 && slot != KnownTagCodec.NO_SLOT) { + int level = (int) (tagHash & KnownTagCodec.LEVEL_TRACE) << 1; // trace-level: flip bit 3 + return ((int) (tagHash >>> 48) << 16) | (slot ^ level); + } int hash = (int) (tagHash >>> 48) ^ (int) tagHash; return hash == 0 ? 0xDD06 : hash; } @@ -1224,8 +1281,7 @@ public TagMap() { * optimizations can treat it as fixed. */ private TagMap(TagMap parent) { - // needs to be a power of 2 for bucket masking calculation to work as intended - this.buckets = new Object[1 << 4]; + this.buckets = HashSupport.newBuckets(); this.size = 0; this.frozen = false; this.parent = parent; @@ -1333,6 +1389,45 @@ public String getString(String tag) { return entry == null ? null : entry.stringValue(); } + /* TagStore, for standalone maps handed to the pipeline: TagMap's own get and set. */ + + @Override + public Object load(String tag) { + return this.getObject(tag); + } + + @Override + public String loadString(String tag) { + return this.getString(tag); + } + + @Override + public Object load(long tagId) { + Entry entry = this.getEntry(tagId); + return entry == null ? null : entry.objectValue(); + } + + @Override + public String loadString(long tagId) { + Entry entry = this.getEntry(tagId); + return entry == null ? null : entry.stringValue(); + } + + @Override + public void store(String tag, Object value) { + this.set(tag, value); + } + + @Override + public void store(long tagId, Object value) { + this.set(tagId, value); + } + + @Override + public void store(EntryReader entry) { + this.set(entry); + } + public boolean getBoolean(String tag) { return this.getBooleanOrDefault(tag, false); } @@ -1452,22 +1547,7 @@ private Entry getEntry(String canonicalTag, long tagHash) { /** Looks up an entry in this map's own buckets only — no read-through to the parent. */ private Entry getLocalEntry(String tag, long tagHash) { - Object[] thisBuckets = this.buckets; - int hash = Entry.bucketHash(tagHash); - return findInBucket(thisBuckets[hash & (thisBuckets.length - 1)], hash, tag); - } - - /** - * Finds an entry by hash/tag within a single bucket object (Entry | BucketGroup chain | null). - */ - private static Entry findInBucket(Object bucket, int hash, String tag) { - if (bucket instanceof Entry) { - Entry tagEntry = (Entry) bucket; - return tagEntry.matches(tag) ? tagEntry : null; - } else if (bucket instanceof BucketGroup) { - return ((BucketGroup) bucket).findInChain(hash, tag); - } - return null; + return HashSupport.find(this.buckets, Entry.bucketHash(tagHash), tag); } /** @@ -1490,9 +1570,7 @@ boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) { return false; } // shadowed by a nearer level's local entry - Object[] nearerBuckets = nearer.buckets; - Object nearerBucket = nearerBuckets[hash & (nearerBuckets.length - 1)]; - if (findInBucket(nearerBucket, hash, tag) != null) { + if (HashSupport.find(nearer.buckets, hash, tag) != null) { return false; } } @@ -1502,7 +1580,9 @@ boolean parentEntryVisible(Entry parentEntry, TagMap fromAncestor) { @Deprecated @Override public Object put(@Nonnull String tag, Object value) { - TagMap.Entry entry = this.getAndSet(anyEntryFor(tag, value)); + // a null value removes the tag, rather than mapping it to null as HashMap would + TagMap.Entry entry = + value == null ? this.getAndRemove(tag) : this.getAndSet(anyEntryFor(tag, value)); return entry == null ? null : entry.objectValue(); } @@ -1556,7 +1636,12 @@ static Entry doubleEntryFor(String tag, double value) { return tagId != 0 ? Entry.newDoubleEntry(tagId, value) : Entry.newDoubleEntry(tag, value); } - public void set(@Nonnull String tag, @Nonnull Object value) { + /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ + public void set(@Nonnull String tag, @Nullable Object value) { + if (value == null) { + this.getAndRemove(tag); + return; + } long tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { this.set(tagId, value); @@ -1565,7 +1650,12 @@ public void set(@Nonnull String tag, @Nonnull Object value) { } } - public void set(@Nonnull String tag, @Nonnull CharSequence value) { + /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ + public void set(@Nonnull String tag, @Nullable CharSequence value) { + if (value == null) { + this.getAndRemove(tag); + return; + } long tagId = KnownTagCodec.keyOf(tag); if (tagId != 0) { this.set(tagId, value); @@ -1680,11 +1770,21 @@ public void setKnown(long tagId, double value) { } } - public void set(long tagId, @Nonnull Object value) { + /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ + public void set(long tagId, @Nullable Object value) { + if (value == null) { + this.getAndRemove(tagId); + return; + } this.putEntry(Entry.newAnyEntry(tagId, value)); } - public void set(long tagId, @Nonnull CharSequence value) { + /** A null value removes the tag, as {@code setTag(tag, null)} does on a span. */ + public void set(long tagId, @Nullable CharSequence value) { + if (value == null) { + this.getAndRemove(tagId); + return; + } this.putEntry(Entry.newObjectEntry(tagId, value)); } @@ -1751,57 +1851,19 @@ Entry putEntry(@Nonnull Entry newEntry) { this.removedFromParent.remove(newEntry.tag); } - Object[] thisBuckets = this.buckets; - - int newHash = newEntry.hash(); - int bucketIndex = newHash & (thisBuckets.length - 1); - - Object bucket = thisBuckets[bucketIndex]; - if (bucket == null) { - thisBuckets[bucketIndex] = newEntry; - + Entry replaced = HashSupport.put(this.buckets, newEntry); + if (replaced == null) { this.size += 1; - return null; - } else if (bucket instanceof Entry) { - Entry existingEntry = (Entry) bucket; - if (existingEntry.matches(newEntry.tag)) { - thisBuckets[bucketIndex] = newEntry; - - // replaced existing entry - no size change - return existingEntry; - } else { - thisBuckets[bucketIndex] = - new BucketGroup(existingEntry.hash(), existingEntry, newHash, newEntry); - - this.size += 1; - return null; - } - } else if (bucket instanceof BucketGroup) { - BucketGroup lastGroup = (BucketGroup) bucket; - - BucketGroup containingGroup = lastGroup.findContainingGroupInChain(newHash, newEntry.tag); - if (containingGroup != null) { - // replaced existing entry - no size change - return containingGroup._replace(newHash, newEntry); - } - - if (!lastGroup.insertInChain(newHash, newEntry)) { - thisBuckets[bucketIndex] = new BucketGroup(newHash, newEntry, lastGroup); - } - this.size += 1; - return null; } - - // unreachable - return null; + return replaced; } public Entry getAndSet(@Nonnull String tag, Object value) { - return this.getAndSet(anyEntryFor(tag, value)); + return value == null ? this.getAndRemove(tag) : this.getAndSet(anyEntryFor(tag, value)); } public Entry getAndSet(@Nonnull String tag, CharSequence value) { - return this.getAndSet(objectEntryFor(tag, value)); + return value == null ? this.getAndRemove(tag) : this.getAndSet(objectEntryFor(tag, value)); } public TagMap.Entry getAndSet(@Nonnull String tag, boolean value) { @@ -1870,143 +1932,14 @@ private void putAllOptimizedMap(TagMap that) { } private void putAllMerge(TagMap that) { - Object[] thisBuckets = this.buckets; - Object[] thatBuckets = that.buckets; - - // Since TagMap-s don't support expansion, buckets are perfectly aligned - // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check - // elimination - for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { - Object thatBucket = thatBuckets[i]; - - // if nothing incoming, nothing to do - if (thatBucket == null) continue; - - Object thisBucket = thisBuckets[i]; - if (thisBucket == null) { - // This bucket is null, easy case - // Either copy over the sole entry or clone the BucketGroup chain - - if (thatBucket instanceof Entry) { - thisBuckets[i] = thatBucket; - this.size += 1; - } else if (thatBucket instanceof BucketGroup) { - BucketGroup thatGroup = (BucketGroup) thatBucket; - - BucketGroup thisNewGroup = thatGroup.cloneChain(); - thisBuckets[i] = thisNewGroup; - this.size += thisNewGroup.sizeInChain(); - } - } else if (thisBucket instanceof Entry) { - // This bucket is a single entry, medium complexity case - // If other side is an Entry - just merge the entries into a bucket - // If other side is a BucketGroup - then clone the group and insert the entry normally into - // the cloned group - - Entry thisEntry = (Entry) thisBucket; - int thisHash = thisEntry.hash(); - - if (thatBucket instanceof Entry) { - Entry thatEntry = (Entry) thatBucket; - int thatHash = thatEntry.hash(); - - if (thisHash == thatHash && thisEntry.matches(thatEntry.tag())) { - thisBuckets[i] = thatEntry; - // replacing entry, no size change - } else { - thisBuckets[i] = - new BucketGroup( - thisHash, thisEntry, - thatHash, thatEntry); - this.size += 1; - } - } else if (thatBucket instanceof BucketGroup) { - BucketGroup thatGroup = (BucketGroup) thatBucket; - - // Clone the other group, then place this entry into that group - BucketGroup thisNewGroup = thatGroup.cloneChain(); - int thisNewGroupSize = thisNewGroup.sizeInChain(); - - Entry incomingEntry = thisNewGroup.findInChain(thisHash, thisEntry.tag()); - if (incomingEntry != null) { - // there's already an entry w/ the same tag from the incoming TagMap - // incoming entry clobbers the existing try, so we're done - thisBuckets[i] = thisNewGroup; - - // overlapping group - subtract one for clobbered existing entry - this.size += thisNewGroupSize - 1; - } else if (thisNewGroup.insertInChain(thisHash, thisEntry)) { - // able to add thisEntry into the existing groups - thisBuckets[i] = thisNewGroup; - - // non overlapping group - existing entry already accounted for in this.size - this.size += thisNewGroupSize; - } else { - // unable to add into the existing groups - thisBuckets[i] = new BucketGroup(thisHash, thisEntry, thisNewGroup); - - // non overlapping group - existing entry already accounted for in this.size - this.size += thisNewGroupSize; - } - } - } else if (thisBucket instanceof BucketGroup) { - // This bucket is a BucketGroup, medium to hard case - // If the other side is an entry, just normal insertion procedure - no cloning required - BucketGroup thisGroup = (BucketGroup) thisBucket; - - if (thatBucket instanceof Entry) { - Entry thatEntry = (Entry) thatBucket; - int thatHash = thatEntry.hash(); - - if (thisGroup.replaceInChain(thatHash, thatEntry) != null) { - // replaced existing entry no size change - } else if (thisGroup.insertInChain(thatHash, thatEntry)) { - this.size += 1; - } else { - thisBuckets[i] = new BucketGroup(thatHash, thatEntry, thisGroup); - this.size += 1; - } - } else if (thatBucket instanceof BucketGroup) { - // Most complicated case - need to walk that bucket group chain and update this chain - BucketGroup thatGroup = (BucketGroup) thatBucket; - - // Taking the easy / expensive way out for updating size - int thisPrevGroupSize = thisGroup.sizeInChain(); - - BucketGroup thisNewGroup = thisGroup.replaceOrInsertAllInChain(thatGroup); - int thisNewGroupSize = thisNewGroup.sizeInChain(); - - thisBuckets[i] = thisNewGroup; - this.size += (thisNewGroupSize - thisPrevGroupSize); - } - } - } + this.size += HashSupport.merge(this.buckets, that.buckets); } /* * Specially optimized version of putAll for the common case of destination map being empty */ private void putAllIntoEmptyMap(TagMap that) { - Object[] thisBuckets = this.buckets; - Object[] thatBuckets = that.buckets; - - // Check against both thisBuckets.length && thatBuckets.length is to help the JIT do bound check - // elimination - for (int i = 0; i < thisBuckets.length && i < thatBuckets.length; ++i) { - Object thatBucket = thatBuckets[i]; - - // faster to explicitly null check first, then do instanceof - if (thatBucket == null) { - // do nothing - } else if (thatBucket instanceof BucketGroup) { - // if it is a BucketGroup, then need to clone - BucketGroup thatGroup = (BucketGroup) thatBucket; - - thisBuckets[i] = thatGroup.cloneChain(); - } else { // if ( thatBucket instanceof Entry ) - thisBuckets[i] = thatBucket; - } - } + HashSupport.copyInto(this.buckets, that.buckets); this.size = that.size; } @@ -2019,21 +1952,7 @@ public void fillMap(Map map) { return; } - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - map.put(thisEntry.tag, thisEntry.objectValue()); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.fillMapFromChain(map); - } - } + HashSupport.fillMap(this.buckets, map); } public void fillStringMap(Map stringMap) { @@ -2044,21 +1963,7 @@ public void fillStringMap(Map stringMap) { return; } - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - stringMap.put(thisEntry.tag, thisEntry.stringValue()); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.fillStringMapFromChain(stringMap); - } - } + HashSupport.fillStringMap(this.buckets, stringMap); } @Override @@ -2119,40 +2024,11 @@ private Entry getAndRemove(String canonicalTag, long tagHash) { /** Removes an entry from this map's own buckets only — no parent/tombstone handling. */ private Entry removeLocal(String tag, long tagHash) { - Object[] thisBuckets = this.buckets; - - int hash = Entry.bucketHash(tagHash); - int bucketIndex = hash & (thisBuckets.length - 1); - - Object bucket = thisBuckets[bucketIndex]; - // null bucket case - do nothing - if (bucket instanceof Entry) { - Entry existingEntry = (Entry) bucket; - if (existingEntry.matches(tag)) { - thisBuckets[bucketIndex] = null; - - this.size -= 1; - return existingEntry; - } else { - return null; - } - } else if (bucket instanceof BucketGroup) { - BucketGroup lastGroup = (BucketGroup) bucket; - - BucketGroup containingGroup = lastGroup.findContainingGroupInChain(hash, tag); - if (containingGroup == null) { - return null; - } - - Entry existingEntry = containingGroup._remove(hash, tag); - if (containingGroup._isEmpty()) { - this.buckets[bucketIndex] = lastGroup.removeGroupInChain(containingGroup); - } - + Entry removed = HashSupport.remove(this.buckets, Entry.bucketHash(tagHash), tag); + if (removed != null) { this.size -= 1; - return existingEntry; } - return null; + return removed; } public TagMap copy() { @@ -2186,21 +2062,7 @@ public Stream stream() { @Override public void forEach(Consumer consumer) { - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - consumer.accept(thisEntry); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.forEachInChain(consumer); - } - } + HashSupport.forEach(this.buckets, consumer); // read-through: parent entries not shadowed locally or tombstoned. Kept out of line so the // common parent == null path stays byte-identical to before (small / inlinable). @@ -2235,21 +2097,7 @@ private void forEachParent(Consumer consumer) { } public void forEach(T thisObj, BiConsumer consumer) { - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - consumer.accept(thisObj, thisEntry); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.forEachInChain(thisObj, consumer); - } - } + HashSupport.forEach(this.buckets, thisObj, consumer); // read-through: parent entries not shadowed locally or tombstoned (kept out of line). if (this.parent != null) { @@ -2283,21 +2131,7 @@ private void forEachParent(T thisObj, BiConsumer void forEach( T thisObj, U otherObj, TriConsumer consumer) { - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - - consumer.accept(thisObj, otherObj, thisEntry); - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - thisGroup.forEachInChain(thisObj, otherObj, consumer); - } - } + HashSupport.forEach(this.buckets, thisObj, otherObj, consumer); // read-through: parent entries not shadowed locally or tombstoned (kept out of line). if (this.parent != null) { @@ -2362,38 +2196,7 @@ void checkIntegrity() { // That was done to avoid the extra static initialization needed for an assertion // While that's probably an unnecessary optimization, this method is only called in tests - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object thisBucket = thisBuckets[i]; - - if (thisBucket instanceof Entry) { - Entry thisEntry = (Entry) thisBucket; - int thisHash = thisEntry.hash(); - - int expectedBucket = thisHash & (thisBuckets.length - 1); - if (expectedBucket != i) { - throw new IllegalStateException("incorrect bucket"); - } - } else if (thisBucket instanceof BucketGroup) { - BucketGroup thisGroup = (BucketGroup) thisBucket; - - for (BucketGroup curGroup = thisGroup; curGroup != null; curGroup = curGroup.prev) { - for (int j = 0; j < BucketGroup.LEN; ++j) { - Entry thisEntry = curGroup._entryAt(i); - if (thisEntry == null) continue; - - int thisHash = thisEntry.hash(); - assert curGroup._hashAt(i) == thisHash; - - int expectedBucket = thisHash & (thisBuckets.length - 1); - if (expectedBucket != i) { - throw new IllegalStateException("incorrect bucket"); - } - } - } - } - } + HashSupport.checkBuckets(this.buckets); if (this.size != this.computeSize()) { throw new IllegalStateException("incorrect size"); @@ -2406,37 +2209,11 @@ void checkIntegrity() { } int computeSize() { - Object[] thisBuckets = this.buckets; - - int size = 0; - for (int i = 0; i < thisBuckets.length; ++i) { - Object curBucket = thisBuckets[i]; - - if (curBucket instanceof Entry) { - size += 1; - } else if (curBucket instanceof BucketGroup) { - BucketGroup curGroup = (BucketGroup) curBucket; - size += curGroup.sizeInChain(); - } - } - return size; + return HashSupport.count(this.buckets); } boolean checkIfEmpty() { - Object[] thisBuckets = this.buckets; - - for (int i = 0; i < thisBuckets.length; ++i) { - Object curBucket = thisBuckets[i]; - - if (curBucket instanceof Entry) { - return false; - } else if (curBucket instanceof BucketGroup) { - BucketGroup curGroup = (BucketGroup) curBucket; - if (!curGroup.isEmptyChain()) return false; - } - } - - return true; + return HashSupport.isEmpty(this.buckets); } @Override @@ -2494,27 +2271,7 @@ String toPrettyString() { * debugging */ String toInternalString() { - Object[] thisBuckets = this.buckets; - - StringBuilder ledger = new StringBuilder(128); - for (int i = 0; i < thisBuckets.length; ++i) { - ledger.append('[').append(i).append("] = "); - - Object thisBucket = thisBuckets[i]; - if (thisBucket == null) { - ledger.append("null"); - } else if (thisBucket instanceof Entry) { - ledger.append('{').append(thisBucket).append('}'); - } else if (thisBucket instanceof BucketGroup) { - for (BucketGroup curGroup = (BucketGroup) thisBucket; - curGroup != null; - curGroup = curGroup.prev) { - ledger.append(curGroup).append(" -> "); - } - } - ledger.append('\n'); - } - return ledger.toString(); + return HashSupport.toInternalString(this.buckets); } abstract static class IteratorBase { @@ -2799,6 +2556,31 @@ Entry findInChain(int hash, String tag) { return null; } + /** + * {@link #findInChain} for a known tag: matches on the entry's id, so it needs no name. Only an + * entry stored under {@code tagId} has that id, and the hash check filters first. + */ + Entry findInChainById(int hash, long tagId) { + for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { + Entry curEntry = curGroup._findById(hash, tagId); + if (curEntry != null) return curEntry; + } + return null; + } + + Entry _findById(int hash, long tagId) { + if (this.hash0 == hash && this.entry0.tagHash == tagId) { + return this.entry0; + } else if (this.hash1 == hash && this.entry1.tagHash == tagId) { + return this.entry1; + } else if (this.hash2 == hash && this.entry2.tagHash == tagId) { + return this.entry2; + } else if (this.hash3 == hash && this.entry3.tagHash == tagId) { + return this.entry3; + } + return null; + } + Entry _find(int hash, String tag) { // if ( this._mayContain(hash) ) return null; @@ -2988,6 +2770,28 @@ Entry _remove(int hash, String tag) { return existingEntry; } + BucketGroup cloneChain() { + BucketGroup thisClone = this._cloneEntries(); + + BucketGroup thisPriorClone = thisClone; + for (BucketGroup curGroup = this.prev; curGroup != null; curGroup = curGroup.prev) { + BucketGroup newClone = curGroup._cloneEntries(); + thisPriorClone.prev = newClone; + + thisPriorClone = newClone; + } + + return thisClone; + } + + BucketGroup _cloneEntries() { + return new BucketGroup( + this.hash0, this.entry0, + this.hash1, this.entry1, + this.hash2, this.entry2, + this.hash3, this.entry3); + } + void forEachInChain(Consumer consumer) { for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { curGroup._forEach(consumer); @@ -3028,79 +2832,17 @@ void _forEach(T thisObj, U otherObj, TriConsumer map) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._fillMap(map); - } - } - - void _fillMap(Map map) { - Entry entry0 = this.entry0; - if (entry0 != null) map.put(entry0.tag, entry0.objectValue()); - - Entry entry1 = this.entry1; - if (entry1 != null) map.put(entry1.tag, entry1.objectValue()); - - Entry entry2 = this.entry2; - if (entry2 != null) map.put(entry2.tag, entry2.objectValue()); - - Entry entry3 = this.entry3; - if (entry3 != null) map.put(entry3.tag, entry3.objectValue()); - } - - void fillStringMapFromChain(Map map) { - for (BucketGroup curGroup = this; curGroup != null; curGroup = curGroup.prev) { - curGroup._fillStringMap(map); - } - } - - void _fillStringMap(Map map) { - Entry entry0 = this.entry0; - if (entry0 != null) map.put(entry0.tag, entry0.stringValue()); - - Entry entry1 = this.entry1; - if (entry1 != null) map.put(entry1.tag, entry1.stringValue()); - - Entry entry2 = this.entry2; - if (entry2 != null) map.put(entry2.tag, entry2.stringValue()); - - Entry entry3 = this.entry3; - if (entry3 != null) map.put(entry3.tag, entry3.stringValue()); - } - - BucketGroup cloneChain() { - BucketGroup thisClone = this._cloneEntries(); - - BucketGroup thisPriorClone = thisClone; - for (BucketGroup curGroup = this.prev; curGroup != null; curGroup = curGroup.prev) { - BucketGroup newClone = curGroup._cloneEntries(); - thisPriorClone.prev = newClone; - - thisPriorClone = newClone; - } - - return thisClone; - } - - BucketGroup _cloneEntries() { - return new BucketGroup( - this.hash0, this.entry0, - this.hash1, this.entry1, - this.hash2, this.entry2, - this.hash3, this.entry3); - } - @Override public String toString() { - StringBuilder ledger = new StringBuilder(32); - ledger.append('['); + StringBuilder builder = new StringBuilder(32); + builder.append('['); for (int i = 0; i < BucketGroup.LEN; ++i) { - if (i != 0) ledger.append(", "); + if (i != 0) builder.append(", "); - ledger.append(this._entryAt(i)); + builder.append(this._entryAt(i)); } - ledger.append(']'); - return ledger.toString(); + builder.append(']'); + return builder.toString(); } } diff --git a/internal-api/src/main/java/datadog/trace/api/TagStore.java b/internal-api/src/main/java/datadog/trace/api/TagStore.java new file mode 100644 index 00000000000..b0e63cbf15f --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/TagStore.java @@ -0,0 +1,36 @@ +package datadog.trace.api; + +/** + * A span's tags as stored, for the tracer core's processing pipeline: loads and stores go straight + * to storage, with no interception or interpretation. Thread safety is up to the implementation; + * {@code DDSpanContext}'s expects the span's tag lock to be held. + * + *

    Its own verbs, {@code load} and {@code store}, keep it apart from {@link TagMap}'s {@code + * get}/{@code set} and the span's intercepted {@code getTag}/{@code setTag}, so a type can offer + * several of these without a clash. + * + *

    A known tag can be addressed by its {@link KnownTags} id or by its name, and both address the + * same tag. Prefer the id: it skips classifying the name on every call. A custom tag has no id. + */ +public interface TagStore { + /** The tag's value, or null when it's absent. */ + Object load(String tag); + + /** The tag's value as a string, or null when it's absent. */ + String loadString(String tag); + + /** The known tag's value, or null when it's absent. */ + Object load(long tagId); + + /** The known tag's value as a string, or null when it's absent. */ + String loadString(long tagId); + + /** Stores the tag; a null value removes it. */ + void store(String tag, Object value); + + /** Stores the known tag; a null value removes it. */ + void store(long tagId, Object value); + + /** Stores the entry's tag; a null entry is a no-op. */ + void store(TagMap.EntryReader entry); +} diff --git a/internal-api/src/main/java/datadog/trace/api/endpoint/EndpointResolver.java b/internal-api/src/main/java/datadog/trace/api/endpoint/EndpointResolver.java index c5e47328734..29533b14e18 100644 --- a/internal-api/src/main/java/datadog/trace/api/endpoint/EndpointResolver.java +++ b/internal-api/src/main/java/datadog/trace/api/endpoint/EndpointResolver.java @@ -1,7 +1,8 @@ package datadog.trace.api.endpoint; -import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ENDPOINT; +import static datadog.trace.api.KnownTags.HTTP_ENDPOINT_ID; +import datadog.trace.api.TagStore; import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,9 +51,7 @@ public EndpointResolver(boolean enabled, boolean alwaysSimplifiedEndpoint) { */ @Nullable public String resolveEndpoint( - java.util.Map unsafeTags, - @Nullable String httpRoute, - @Nullable String httpUrl) { + TagStore unsafeTags, @Nullable String httpRoute, @Nullable String httpUrl) { if (!enabled) { return null; } @@ -61,7 +60,7 @@ public String resolveEndpoint( if (alwaysSimplifiedEndpoint) { String endpoint = computeEndpoint(httpUrl); if (endpoint != null) { - unsafeTags.put(HTTP_ENDPOINT, endpoint); + unsafeTags.store(HTTP_ENDPOINT_ID, endpoint); } return endpoint; } @@ -74,7 +73,7 @@ public String resolveEndpoint( // Compute endpoint from URL and tag the span String endpoint = computeEndpoint(httpUrl); if (endpoint != null) { - unsafeTags.put(HTTP_ENDPOINT, endpoint); + unsafeTags.store(HTTP_ENDPOINT_ID, endpoint); } return endpoint; } diff --git a/internal-api/src/main/java/datadog/trace/api/naming/NamingSchema.java b/internal-api/src/main/java/datadog/trace/api/naming/NamingSchema.java index 9ddf3178f3b..7876901a68d 100644 --- a/internal-api/src/main/java/datadog/trace/api/naming/NamingSchema.java +++ b/internal-api/src/main/java/datadog/trace/api/naming/NamingSchema.java @@ -1,6 +1,6 @@ package datadog.trace.api.naming; -import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import java.util.function.Supplier; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -232,7 +232,7 @@ interface ForPeerService { * @param unsafeTags the span tags. Map to be mutated */ @Nonnull - void tags(@Nonnull TagMap unsafeTags); + void tags(@Nonnull TagStore unsafeTags); } interface ForServer { diff --git a/internal-api/src/main/java/datadog/trace/api/naming/v0/PeerServiceNamingV0.java b/internal-api/src/main/java/datadog/trace/api/naming/v0/PeerServiceNamingV0.java index 3e76d657069..004a8e654bd 100644 --- a/internal-api/src/main/java/datadog/trace/api/naming/v0/PeerServiceNamingV0.java +++ b/internal-api/src/main/java/datadog/trace/api/naming/v0/PeerServiceNamingV0.java @@ -1,6 +1,6 @@ package datadog.trace.api.naming.v0; -import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import datadog.trace.api.naming.NamingSchema; import javax.annotation.Nonnull; @@ -12,5 +12,5 @@ public boolean supports() { @Nonnull @Override - public void tags(@Nonnull final TagMap unsafeTags) {} + public void tags(@Nonnull final TagStore unsafeTags) {} } diff --git a/internal-api/src/main/java/datadog/trace/api/naming/v1/PeerServiceNamingV1.java b/internal-api/src/main/java/datadog/trace/api/naming/v1/PeerServiceNamingV1.java index 827a7489e09..4f88c13aa46 100644 --- a/internal-api/src/main/java/datadog/trace/api/naming/v1/PeerServiceNamingV1.java +++ b/internal-api/src/main/java/datadog/trace/api/naming/v1/PeerServiceNamingV1.java @@ -1,7 +1,11 @@ package datadog.trace.api.naming.v1; -import datadog.trace.api.DDTags; -import datadog.trace.api.TagMap; +import static datadog.trace.api.KnownTags.COMPONENT_ID; +import static datadog.trace.api.KnownTags.DD_PEER_SERVICE_SOURCE_ID; +import static datadog.trace.api.KnownTags.PEER_SERVICE_ID; +import static datadog.trace.api.KnownTags.SPAN_KIND_ID; + +import datadog.trace.api.TagStore; import datadog.trace.api.naming.NamingSchema; import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; import datadog.trace.bootstrap.instrumentation.api.Tags; @@ -53,8 +57,8 @@ public boolean supports() { return true; } - private void resolve(@Nonnull final TagMap unsafeTags) { - final Object component = unsafeTags.getObject(Tags.COMPONENT); + private void resolve(@Nonnull final TagStore unsafeTags) { + final Object component = unsafeTags.load(COMPONENT_ID); // avoid issues with UTF8ByteString or others final String componentString = component == null ? null : component.toString(); final String override = overridesByComponent.get(componentString); @@ -71,14 +75,15 @@ private void resolve(@Nonnull final TagMap unsafeTags) { resolveBy(unsafeTags, DEFAULT_PRECURSORS); } - private boolean resolveBy(@Nonnull final TagMap unsafeTags, @Nullable final String[] precursors) { + private boolean resolveBy( + @Nonnull final TagStore unsafeTags, @Nullable final String[] precursors) { if (precursors == null) { return false; } Object value = null; String source = null; for (String precursor : precursors) { - value = unsafeTags.getObject(precursor); + value = unsafeTags.load(precursor); if (value != null) { // we have a match. Use the tag name for the source source = precursor; @@ -90,18 +95,18 @@ private boolean resolveBy(@Nonnull final TagMap unsafeTags, @Nullable final Stri return true; } - private void set(@Nonnull final TagMap unsafeTags, Object value, String source) { + private void set(@Nonnull final TagStore unsafeTags, Object value, String source) { if (value != null) { - unsafeTags.put(Tags.PEER_SERVICE, value); - unsafeTags.put(DDTags.PEER_SERVICE_SOURCE, source); + unsafeTags.store(PEER_SERVICE_ID, value); + unsafeTags.store(DD_PEER_SERVICE_SOURCE_ID, source); } } @Nonnull @Override - public void tags(@Nonnull final TagMap unsafeTags) { + public void tags(@Nonnull final TagStore unsafeTags) { // check span.kind eligibility - final Object kind = unsafeTags.getObject(Tags.SPAN_KIND); + final Object kind = unsafeTags.load(SPAN_KIND_ID); if (Tags.SPAN_KIND_CLIENT.equals(kind) || Tags.SPAN_KIND_PRODUCER.equals(kind)) { // we can calculate the peer service now resolve(unsafeTags); diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemantics.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemantics.java index 9e8a85ef0dc..7eac0369049 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemantics.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemantics.java @@ -5,6 +5,7 @@ import datadog.trace.api.DDTags; import datadog.trace.api.KnownTags; +import datadog.trace.api.TagStore; import datadog.trace.api.cache.RadixTreeCache; import java.net.URI; import java.util.Arrays; @@ -51,6 +52,12 @@ public static Object urlOrPath(final Map tags) { return url != null ? url : tags.get(KnownTags.URL_PATH_NAME); } + /** {@link #urlOrPath(Map)}, from a span's tags as stored. */ + public static Object urlOrPath(final TagStore tags) { + Object url = tags.load(KnownTags.HTTP_URL_ID); + return url != null ? url : tags.load(KnownTags.URL_PATH_ID); + } + public static String redactedUrl(final URI url) { final String full = url.toString(); // URI does not parse user-info for some hosts, including underscores and Unicode names. diff --git a/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java b/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java new file mode 100644 index 00000000000..71b959826a2 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/HashStoreTest.java @@ -0,0 +1,273 @@ +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.Assertions.assertTrue; + +import datadog.trace.api.TagMap.Entry; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** A custom-tag store reads through to its parent, and a local removal hides the inherited tag. */ +class HashStoreTest { + private static final String A = "custom.a"; + private static final String B = "custom.b"; + private static final String C = "custom.c"; + + @Test + void storedTagsAreFound() { + HashStore store = HashStore.create(null); + + store.store(A, "1"); + store.store(B, 2); + + assertEquals("1", store.getEntry(A).objectValue()); + assertEquals(2, store.getEntry(B).objectValue()); + assertEquals(2, store.size()); + store.checkIntegrity(); + } + + @Test + void readsThroughToItsParent() { + HashStore parent = parentWith(A, "parent"); + HashStore child = HashStore.create(parent); + + assertEquals("parent", child.getEntry(A).objectValue()); + assertEquals(1, child.size()); + assertFalse(child.isEmpty()); + } + + @Test + void aLocalTagShadowsTheParents() { + HashStore child = HashStore.create(parentWith(A, "parent")); + + child.store(A, "child"); + + assertEquals("child", child.getEntry(A).objectValue()); + assertEquals(1, child.size()); + assertEquals(tags(A, "child"), visible(child)); + } + + @Test + void removingAnInheritedTagHidesIt() { + HashStore parent = parentWith(A, "parent"); + HashStore child = HashStore.create(parent); + + Entry removed = child.remove(A); + + assertSame(parent.getEntry(A), removed); + assertNull(child.getEntry(A)); + assertEquals(0, child.size()); + assertTrue(child.isEmpty()); + assertEquals(tags(), visible(child)); + assertEquals("parent", parent.getEntry(A).objectValue()); + } + + @Test + void getAndPutAfterRemovingAnInheritedTagReportsNoPriorValue() { + HashStore child = HashStore.create(parentWith(A, "parent")); + child.remove(A); + + Entry prior = child.getAndPut(Entry.create(A, "child")); + + assertNull(prior); + assertEquals("child", child.getEntry(A).objectValue()); + assertEquals(1, child.size()); + } + + @Test + void getAndPutReportsTheInheritedPriorValue() { + HashStore parent = parentWith(A, "parent"); + HashStore child = HashStore.create(parent); + + Entry prior = child.getAndPut(Entry.create(A, "child")); + + assertSame(parent.getEntry(A), prior); + assertEquals("child", child.getEntry(A).objectValue()); + } + + @Test + void forEachSkipsShadowedAndRemovedParentTags() { + HashStore parent = HashStore.create(null); + parent.store(A, "parent-a"); + parent.store(B, "parent-b"); + HashStore child = HashStore.create(parent); + child.store(B, "child-b"); + child.store(C, "child-c"); + child.remove(A); + + assertEquals(tags(B, "child-b", C, "child-c"), visible(child)); + assertEquals(2, child.size()); + } + + @Test + void putAllLocalMergesAnotherStoresLocalTags() { + HashStore target = HashStore.create(null); + target.store(A, "old"); + HashStore source = HashStore.create(null); + source.store(A, "new"); + source.store(B, "b"); + + target.putAllLocal(source); + + assertEquals(tags(A, "new", B, "b"), visible(target)); + assertEquals(2, target.size()); + target.checkIntegrity(); + } + + @Test + void typedStoresRoundTrip() { + HashStore store = HashStore.create(null); + + store.store(A, true); + store.store(B, 7L); + store.store(C, 1.5f); + store.store("custom.d", 2.5d); + store.store("custom.e", (CharSequence) "chars"); + + assertEquals(true, store.getEntry(A).objectValue()); + assertEquals(7L, store.getEntry(B).objectValue()); + assertEquals(1.5f, store.getEntry(C).objectValue()); + assertEquals(2.5d, store.getEntry("custom.d").objectValue()); + assertEquals("chars", store.getEntry("custom.e").objectValue().toString()); + store.checkIntegrity(); + } + + @Test + void removingALocalTagWithNoParent() { + HashStore store = HashStore.create(null); + store.store(A, "1"); + + assertEquals("1", store.remove(A).objectValue()); + assertNull(store.remove(A)); + assertEquals(0, store.size()); + } + + @Test + void removingAnInheritedTagTwiceReportsItOnce() { + HashStore child = HashStore.create(parentWith(A, "parent")); + + assertEquals("parent", child.remove(A).objectValue()); + assertNull(child.remove(A)); + } + + @Test + void getAndPutReportsTheLocalPriorValue() { + HashStore store = HashStore.create(null); + store.store(A, "old"); + + assertEquals("old", store.getAndPut(Entry.create(A, "new")).objectValue()); + assertNull(store.getAndPut(Entry.create(B, "b"))); + } + + @Test + void sizesAcrossLevels() { + HashStore parent = parentWith(A, "parent"); + HashStore child = HashStore.create(parent); + assertFalse(child.isDefinitelyEmpty()); + assertEquals(1, child.estimateSize()); + + HashStore empty = HashStore.create(HashStore.create(null)); + assertTrue(empty.isDefinitelyEmpty()); + assertTrue(empty.isEmpty()); + assertEquals(0, empty.estimateSize()); + } + + /** Enough parent tags to form bucket groups, so read-through walks group chains too. */ + @Test + void readThroughWalksBucketGroups() { + HashStore parent = HashStore.create(null); + Map expected = new HashMap<>(); + for (int i = 0; i < 64; i++) { + parent.store("custom.tag" + i, i); + expected.put("custom.tag" + i, i); + } + HashStore child = HashStore.create(parent); + child.remove("custom.tag0"); + expected.remove("custom.tag0"); + child.store("custom.tag1", "local"); + expected.put("custom.tag1", "local"); + + assertEquals(expected.size(), child.size()); + assertEquals(expected, visible(child)); + + Map viaConsumer = new HashMap<>(); + child.forEach(entry -> viaConsumer.put(entry.tag(), entry.objectValue())); + assertEquals(expected, viaConsumer); + + Map viaTwoContexts = new HashMap<>(); + child.forEach( + viaTwoContexts, "-", (map, unused, entry) -> map.put(entry.tag(), entry.objectValue())); + assertEquals(expected, viaTwoContexts); + + for (int i = 1; i < 64; i++) { + child.remove("custom.tag" + i); + } + assertTrue(child.isEmpty()); + } + + @Test + void aCopyIsIndependent() { + HashStore child = HashStore.create(parentWith(A, "parent")); + child.store(B, "b"); + child.remove(A); + + HashStore copy = child.copy(); + copy.store(A, "copy"); + copy.store(B, "copy-b"); + + assertEquals(tags(B, "b"), visible(child)); + assertEquals(tags(A, "copy", B, "copy-b"), visible(copy)); + } + + @Test + void putAllLocalHandlesEmptyStores() { + HashStore target = HashStore.create(null); + target.putAllLocal(HashStore.create(null)); + assertEquals(0, target.size()); + + HashStore source = HashStore.create(null); + source.store(A, "a"); + target.putAllLocal(source); + assertEquals(tags(A, "a"), visible(target)); + target.checkIntegrity(); + } + + @Test + void fillsMapsWithLocalTags() { + HashStore store = HashStore.create(null); + store.store(A, 1); + + Map objects = new HashMap<>(); + store.fillMapLocal(objects); + Map strings = new HashMap<>(); + store.fillStringMapLocal(strings); + + assertEquals(tags(A, 1), objects); + assertEquals("1", strings.get(A)); + assertTrue(store.toInternalString().contains(A)); + } + + private static Map tags(Object... tagsAndValues) { + Map tags = new HashMap<>(); + for (int i = 0; i < tagsAndValues.length; i += 2) { + tags.put((String) tagsAndValues[i], tagsAndValues[i + 1]); + } + return tags; + } + + private static HashStore parentWith(String tag, Object value) { + HashStore parent = HashStore.create(null); + parent.store(tag, value); + return parent; + } + + private static Map visible(HashStore store) { + Map tags = new HashMap<>(); + store.forEach(tags, (map, entry) -> map.put(entry.tag(), entry.objectValue())); + return tags; + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java new file mode 100644 index 00000000000..770b4134f73 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreContractTest.java @@ -0,0 +1,554 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import datadog.trace.api.TagMap.EntryReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * The {@link IdStore} contract, run against both stores in the normal test JVM: the dense store is + * behind a flag that is off by default, so the shared {@link TagMap} suites only reach the hash + * store. Each store is built from a parent of its own kind, as the factories do. + */ +class IdStoreContractTest { + + static Stream stores() { + return Stream.of( + arguments(named("HashIdStore", (Function) HashIdStore::new))); + } + + /** Every known tag id, in declaration order. */ + private static List knownIds() throws IllegalAccessException { + List ids = new ArrayList<>(); + for (java.lang.reflect.Field field : KnownTags.class.getFields()) { + if (field.getType() == long.class) { + long id = field.getLong(null); + if (KnownTagCodec.isKnown(id) && !ids.contains(id)) { + ids.add(id); + } + } + } + return ids; + } + + private static Map visible(IdStore store) { + Map seen = new HashMap<>(); + store.forEach(entry -> assertNull(seen.put(entry.tag(), entry.objectValue()), entry.tag())); + return seen; + } + + @ParameterizedTest + @MethodSource("stores") + void typedSettersRoundTripThroughTheReader(Function newStore) { + IdStore store = newStore.apply(null); + store.store(KnownTags.HTTP_ROUTE_ID, (Object) "/users"); + store.store(KnownTags.HTTP_METHOD_ID, (CharSequence) "GET"); + store.store(KnownTags.ERROR_ID, true); + store.store(KnownTags.HTTP_STATUS_CODE_ID, 200); + store.store(KnownTags.PEER_PORT_ID, 5432L); + store.store(KnownTags.DD_AGENT_PSR_ID, 0.5f); + store.store(KnownTags.DB_TYPE_ID, (Object) "postgres"); + store.store(KnownTags.COMPONENT_ID, (CharSequence) "jdbc"); + store.store(KnownTags.SPAN_KIND_ID, (Object) "client"); + store.store(KnownTags.DB_POOL_NAME_ID, (Object) "pool"); + store.store(KnownTags.HTTP_URL_ID, (Object) "http://x"); + store.store(KnownTags.DB_INSTANCE_ID, 2.5d); + + assertEquals("/users", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals("GET", store.load(KnownTags.HTTP_METHOD_ID)); + + EntryReader flag = store.getEntryReader(KnownTags.ERROR_ID); + assertTrue(flag.booleanValue()); + assertEquals(EntryReader.BOOLEAN, flag.type()); + assertFalse(flag.isNumber()); + + EntryReader status = store.getEntryReader(KnownTags.HTTP_STATUS_CODE_ID); + assertEquals(200, status.intValue()); + assertEquals(200L, status.longValue()); + assertTrue(status.is(EntryReader.INT)); + assertTrue(status.isNumericPrimitive()); + assertFalse(status.isObject()); + assertEquals(KnownTags.HTTP_STATUS_CODE_ID, status.tagId()); + assertEquals("http.status_code", status.tag()); + + assertEquals(5432L, store.getEntryReader(KnownTags.PEER_PORT_ID).longValue()); + assertEquals(0.5f, store.getEntryReader(KnownTags.DD_AGENT_PSR_ID).floatValue()); + assertEquals(2.5d, store.getEntryReader(KnownTags.DB_INSTANCE_ID).doubleValue()); + + EntryReader route = store.getEntryReader(KnownTags.HTTP_ROUTE_ID); + assertEquals("/users", route.stringValue()); + assertTrue(route.isObject()); + assertEquals("/users", route.entry().objectValue()); + assertEquals("http.route", route.mapEntry().getKey()); + + assertEquals(12, store.size()); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void putAndGetAndPutReturnThePriorValue(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore store = newStore.apply(parent); + + assertNull(store.put(TagMap.Entry.newAnyEntry(KnownTags.HTTP_METHOD_ID, "GET"))); + EntryReader prior = store.put(TagMap.Entry.newAnyEntry(KnownTags.HTTP_METHOD_ID, "POST")); + assertEquals("GET", prior.objectValue()); + + // getAndPut reports the prior visible value, inherited included + EntryReader inherited = + store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/local")); + assertEquals("/inherited", inherited.objectValue()); + assertEquals("/local", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals( + "/local", + store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/again")).objectValue()); + assertNull(store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.COMPONENT_ID, "okhttp"))); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void removingAnInheritedTagHidesItUntilItIsSetAgain(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.store(KnownTags.COMPONENT_ID, "okhttp"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + assertTrue(store.hasParent()); + assertEquals(3, store.size()); + + assertEquals("/inherited", store.remove(KnownTags.HTTP_ROUTE_ID).objectValue()); + assertNull(store.load(KnownTags.HTTP_ROUTE_ID)); + assertNull(store.remove(KnownTags.HTTP_ROUTE_ID), "already hidden"); + assertEquals(2, store.size()); + assertEquals("/inherited", parent.load(KnownTags.HTTP_ROUTE_ID), "parent untouched"); + + // a local value that also hides an inherited one is removed with its tombstone in place + store.store(KnownTags.COMPONENT_ID, "local"); + assertEquals("local", store.remove(KnownTags.COMPONENT_ID).objectValue()); + assertNull(store.load(KnownTags.COMPONENT_ID)); + + assertEquals("GET", store.remove(KnownTags.HTTP_METHOD_ID).objectValue()); + assertNull(store.remove(KnownTags.HTTP_METHOD_ID), "absent"); + assertTrue(store.isEmpty()); + assertTrue(store.isDefinitelyEmpty() || store.estimateSize() >= 0); + + // setting a hidden tag again revives it + store.store(KnownTags.HTTP_ROUTE_ID, "/revived"); + assertEquals("/revived", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals(1, store.size()); + assertFalse(store.isEmpty()); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void sizeAndEmptinessCountWhatIsVisible(Function newStore) { + IdStore empty = newStore.apply(null); + assertTrue(empty.isEmpty()); + assertTrue(empty.isDefinitelyEmpty()); + assertEquals(0, empty.estimateSize()); + assertFalse(empty.hasParent()); + + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); + parent.store(KnownTags.HTTP_METHOD_ID, "GET"); + IdStore child = newStore.apply(parent); + assertFalse(child.isEmpty(), "inherits visible tags"); + assertFalse(child.isDefinitelyEmpty()); + + child.store(KnownTags.HTTP_METHOD_ID, "POST"); // shadows + child.store(KnownTags.COMPONENT_ID, "okhttp"); + assertEquals(3, child.size()); + assertTrue(child.estimateSize() >= 3); + + child.remove(KnownTags.HTTP_ROUTE_ID); + child.remove(KnownTags.HTTP_METHOD_ID); + child.remove(KnownTags.COMPONENT_ID); + assertTrue(child.isEmpty(), "every inherited tag hidden"); + assertEquals(0, child.size()); + } + + @ParameterizedTest + @MethodSource("stores") + void everyIterationSeesEachVisibleTagOnce(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); + parent.store(KnownTags.HTTP_METHOD_ID, "GET"); + parent.store(KnownTags.COMPONENT_ID, "okhttp"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.HTTP_METHOD_ID, "POST"); // shadows the parent's + store.store(KnownTags.SPAN_KIND_ID, "client"); + store.remove(KnownTags.COMPONENT_ID); // hides the parent's + + Map expected = new HashMap<>(); + expected.put("http.route", "/a"); + expected.put("http.method", "POST"); + expected.put("span.kind", "client"); + assertEquals(expected, visible(store)); + + Map viaContext = new HashMap<>(); + store.forEach(viaContext, (map, entry) -> map.put(entry.tag(), entry.objectValue())); + assertEquals(expected, viaContext); + + Map viaTwoContexts = new HashMap<>(); + store.forEach( + viaTwoContexts, "-", (map, suffix, entry) -> map.put(entry.tag(), entry.objectValue())); + assertEquals(expected, viaTwoContexts); + + Map viaCursor = new HashMap<>(); + IdStore.Cursor cursor = store.cursor(); + for (EntryReader entry = cursor.next(); entry != null; entry = cursor.next()) { + assertNull(viaCursor.put(entry.tag(), entry.objectValue()), entry.tag()); + } + assertEquals(expected, viaCursor); + } + + @ParameterizedTest + @MethodSource("stores") + void aCopyIsIndependent(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + store.remove(KnownTags.HTTP_ROUTE_ID); + + IdStore copy = store.copy(); + copy.store(KnownTags.HTTP_METHOD_ID, "POST"); + copy.store(KnownTags.HTTP_ROUTE_ID, "/copy"); + + assertEquals("GET", store.load(KnownTags.HTTP_METHOD_ID)); + assertNull(store.load(KnownTags.HTTP_ROUTE_ID), "the original keeps its tombstone"); + assertEquals("POST", copy.load(KnownTags.HTTP_METHOD_ID)); + assertEquals("/copy", copy.load(KnownTags.HTTP_ROUTE_ID)); + copy.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void putAllLocalMergesAnotherStoresTags(Function newStore) { + IdStore source = newStore.apply(null); + source.store(KnownTags.HTTP_ROUTE_ID, "/a"); + source.store(KnownTags.HTTP_METHOD_ID, "GET"); + + IdStore empty = newStore.apply(null); + empty.putAllLocal(source); // nothing here yet + assertEquals(visible(source), visible(empty)); + + IdStore merged = newStore.apply(null); + merged.store(KnownTags.HTTP_METHOD_ID, "POST"); + merged.store(KnownTags.COMPONENT_ID, "okhttp"); + merged.putAllLocal(source); + assertEquals("GET", merged.load(KnownTags.HTTP_METHOD_ID), "the source wins"); + assertEquals("okhttp", merged.load(KnownTags.COMPONENT_ID)); + assertEquals(3, merged.size()); + + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore tombstoned = newStore.apply(parent); + tombstoned.remove(KnownTags.HTTP_ROUTE_ID); + tombstoned.putAllLocal(source); // replaces the tombstone + assertEquals("/a", tombstoned.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals(2, tombstoned.size()); + + newStore.apply(null).putAllLocal(newStore.apply(null)); // an empty source is a no-op + merged.checkIntegrity(); + tombstoned.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void fillsCopyTheLocalTags(Function newStore) { + IdStore store = newStore.apply(null); + store.store(KnownTags.HTTP_ROUTE_ID, "/a"); + store.store(KnownTags.HTTP_STATUS_CODE_ID, 200); + + Map map = new HashMap<>(); + store.fillMapLocal(map); + assertEquals("/a", map.get("http.route")); + assertEquals(200, map.get("http.status_code")); + + Map strings = new HashMap<>(); + store.fillStringMapLocal(strings); + assertEquals("200", strings.get("http.status_code")); + } + + @ParameterizedTest + @MethodSource("stores") + void growsPastItsFirstCapacity(Function newStore) throws Exception { + IdStore store = newStore.apply(null); + List ids = knownIds(); + int n = Math.min(40, ids.size()); + for (int i = 0; i < n; ++i) { + store.store(ids.get(i), "v" + i); + } + for (int i = 0; i < n; ++i) { + assertEquals("v" + i, store.load(ids.get(i))); + } + assertEquals(n, store.size()); + assertTrue(store.computeSize() > 0); + assertNotNull(store.toInternalString()); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void putAllLocalMergesOverlappingStoresWithCollidingTags(Function newStore) + throws Exception { + // enough tags that buckets hold groups of colliding entries on both sides of the merge + List ids = knownIds(); + IdStore target = newStore.apply(null); + IdStore source = newStore.apply(null); + for (int i = 0; i < 30; ++i) { + target.store(ids.get(i), "target" + i); + } + for (int i = 20; i < 50; ++i) { + source.store(ids.get(i), "source" + i); + } + target.putAllLocal(source); + assertEquals(50, target.size()); + for (int i = 0; i < 50; ++i) { + assertEquals(i < 20 ? "target" + i : "source" + i, target.load(ids.get(i))); + } + target.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void getAndPutRevivesARemovedInheritedTag(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore store = newStore.apply(parent); + store.remove(KnownTags.HTTP_ROUTE_ID); + + // hidden, so there is no visible prior value + assertNull(store.getAndPut(TagMap.Entry.newAnyEntry(KnownTags.HTTP_ROUTE_ID, "/revived"))); + assertEquals("/revived", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals(1, store.size()); + store.checkIntegrity(); + } + + @ParameterizedTest + @MethodSource("stores") + void readsGoThroughEveryAncestor(Function newStore) { + IdStore grandparent = newStore.apply(null); + grandparent.store(KnownTags.HTTP_ROUTE_ID, "/grandparent"); + grandparent.store(KnownTags.COMPONENT_ID, "okhttp"); + IdStore parent = newStore.apply(grandparent); + parent.store(KnownTags.HTTP_METHOD_ID, "GET"); + parent.remove(KnownTags.COMPONENT_ID); + IdStore store = newStore.apply(parent); + + assertEquals("/grandparent", store.load(KnownTags.HTTP_ROUTE_ID)); + assertEquals("GET", store.load(KnownTags.HTTP_METHOD_ID)); + assertNull(store.load(KnownTags.COMPONENT_ID), "hidden by the parent"); + assertEquals(2, store.size()); + Map expected = new HashMap<>(); + expected.put("http.route", "/grandparent"); + expected.put("http.method", "GET"); + assertEquals(expected, visible(store)); + } + + @ParameterizedTest + @MethodSource("stores") + void getStringFormatsTheVisibleValue(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + parent.store(KnownTags.COMPONENT_ID, "hidden"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.PEER_PORT_ID, 5432); + store.store(KnownTags.DB_INSTANCE_ID, 1.5d); + store.remove(KnownTags.COMPONENT_ID); + + assertEquals("5432", store.loadString(KnownTags.PEER_PORT_ID)); + assertEquals("1.5", store.loadString(KnownTags.DB_INSTANCE_ID)); + assertEquals("/inherited", store.loadString(KnownTags.HTTP_ROUTE_ID)); + assertNull(store.loadString(KnownTags.COMPONENT_ID), "hidden by the removal"); + assertNull(store.loadString(KnownTags.HTTP_METHOD_ID)); + assertThrows( + IllegalArgumentException.class, () -> store.loadString(KnownTagCodec.makeTagId(9999))); + } + + @ParameterizedTest + @MethodSource("stores") + void aReaderWithNoEntryIsANoOp(Function newStore) { + IdStore store = newStore.apply(null); + store.store(KnownTags.HTTP_ROUTE_ID, "/a"); + NoEntryReader empty = new NoEntryReader("http.route", KnownTags.HTTP_ROUTE_ID); + + assertNull(store.put(empty)); + assertNull(store.getAndPut(empty)); + assertEquals("/a", store.load(KnownTags.HTTP_ROUTE_ID), "left as it was"); + assertEquals(1, store.size()); + } + + @ParameterizedTest + @MethodSource("stores") + void getEntryReturnsAnEntryTheCallerMayKeep(Function newStore) { + IdStore parent = newStore.apply(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/inherited"); + IdStore store = newStore.apply(parent); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + + TagMap.Entry method = store.getEntry(KnownTags.HTTP_METHOD_ID); + TagMap.Entry route = store.getEntry(KnownTags.HTTP_ROUTE_ID); + EntryReader methodReader = store.getEntryReader(KnownTags.HTTP_METHOD_ID); + EntryReader routeReader = store.getEntryReader(KnownTags.HTTP_ROUTE_ID); + + // kept side by side, each still holds its own tag + assertEquals("GET", method.objectValue()); + assertEquals(KnownTags.HTTP_METHOD_ID, method.tagId()); + assertEquals("/inherited", route.objectValue()); + assertEquals("GET", methodReader.objectValue()); + assertEquals("/inherited", routeReader.objectValue()); + assertNull(store.getEntry(KnownTags.COMPONENT_ID)); + assertThrows( + IllegalArgumentException.class, () -> store.getEntry(KnownTagCodec.makeTagId(9999))); + } + + @ParameterizedTest + @MethodSource("stores") + void anUnknownIdIsRejected(Function newStore) { + IdStore store = newStore.apply(null); + long unknown = KnownTagCodec.makeTagId(9999); + assertThrows(IllegalArgumentException.class, () -> store.load(unknown)); + assertThrows(IllegalArgumentException.class, () -> store.remove(unknown)); + } + + /** An EntryReader with no entry and no value, as a caller outside the tracer may pass. */ + static final class NoEntryReader implements TagMap.EntryReader { + private final String tag; + private final long tagId; + + NoEntryReader(String tag, long tagId) { + this.tag = tag; + this.tagId = tagId; + } + + @Override + public String tag() { + return tag; + } + + @Override + public long tagId() { + return tagId; + } + + @Override + public byte type() { + return TagMap.EntryReader.OBJECT; + } + + @Override + public boolean is(byte type) { + return false; + } + + @Override + public boolean isNumericPrimitive() { + return false; + } + + @Override + public boolean isNumber() { + return false; + } + + @Override + public boolean isObject() { + return true; + } + + @Override + public Object objectValue() { + return null; + } + + @Override + public String stringValue() { + return null; + } + + @Override + public boolean booleanValue() { + return false; + } + + @Override + public int intValue() { + return 0; + } + + @Override + public long longValue() { + return 0; + } + + @Override + public float floatValue() { + return 0; + } + + @Override + public double doubleValue() { + return 0; + } + + @Override + public java.util.Map.Entry mapEntry() { + return null; + } + + @Override + public TagMap.Entry entry() { + return null; + } + } + + /** + * The cursor walks single-entry buckets, bucket groups and chains of groups, reads through to the + * parent, and skips inherited tags removed or shadowed locally: it sees what forEach sees. + */ + @ParameterizedTest + @MethodSource("stores") + void cursorSeesWhatForEachSees(Function newStore) + throws IllegalAccessException { + List ids = knownIds(); + IdStore parent = newStore.apply(null); + for (int i = 0; i < ids.size(); i += 3) { + parent.store(ids.get(i), "parent-" + i); + } + IdStore store = newStore.apply(parent); + for (int i = 0; i < ids.size(); i += 2) { + store.store(ids.get(i), "local-" + i); + } + store.remove(ids.get(3)); // inherited, not shadowed: removal hides it + + Map viaCursor = new HashMap<>(); + IdStore.Cursor cursor = store.cursor(); + for (EntryReader entry = cursor.next(); entry != null; entry = cursor.next()) { + assertNull(viaCursor.put(entry.tag(), entry.objectValue()), entry.tag()); + } + + assertEquals(visible(store), viaCursor); + assertNull(viaCursor.get(KnownTagCodec.nameOf(ids.get(3)))); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/IdStoreFactoryTest.java b/internal-api/src/test/java/datadog/trace/api/IdStoreFactoryTest.java new file mode 100644 index 00000000000..149e842af23 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/IdStoreFactoryTest.java @@ -0,0 +1,35 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** The JVM's store factory builds working stores, whichever implementation it is. */ +class IdStoreFactoryTest { + private static final IdStoreFactory FACTORY = IdStoreFactory.Default.INSTANCE; + + @Test + void createdStoreReadsThroughToItsParent() { + IdStore parent = FACTORY.create(null); + parent.store(KnownTags.HTTP_ROUTE_ID, "/a"); + + IdStore store = FACTORY.create(parent); + + assertEquals("/a", store.load(KnownTags.HTTP_ROUTE_ID)); + store.store(KnownTags.HTTP_METHOD_ID, "GET"); + assertEquals("GET", store.load(KnownTags.HTTP_METHOD_ID)); + assertNull(parent.load(KnownTags.HTTP_METHOD_ID)); + } + + @Test + void createdEmptyStoreHasNoParent() { + IdStore store = FACTORY.createEmpty(); + + assertTrue(store.isEmpty()); + assertTrue(!store.hasParent()); + store.store(KnownTags.HTTP_ROUTE_ID, "/a"); + assertEquals("/a", store.load(KnownTags.HTTP_ROUTE_ID)); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapBucketHashTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapBucketHashTest.java new file mode 100644 index 00000000000..7a58dbe3534 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/TagMapBucketHashTest.java @@ -0,0 +1,67 @@ +package datadog.trace.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** How {@link TagMap.Entry#bucketHash} places known and custom tags in the buckets. */ +class TagMapBucketHashTest { + private static final int BUCKET_MASK = 15; // TagMap's 16 buckets + + /** Every known tag id the registry generated. */ + static List knownTagIds() throws IllegalAccessException { + List ids = new ArrayList<>(); + for (Field field : KnownTags.class.getFields()) { + if (Modifier.isStatic(field.getModifiers()) + && field.getType() == long.class + && field.getName().endsWith("_ID")) { + ids.add(field.getLong(null)); + } + } + return ids; + } + + @Test + void knownTagsGetDistinctNonzeroBucketHashes() throws IllegalAccessException { + List ids = knownTagIds(); + assertFalse(ids.isEmpty()); + Set hashes = new HashSet<>(); + for (long id : ids) { + int hash = TagMap.Entry.bucketHash(id); + assertNotEquals(0, hash, "zero marks a vacant BucketGroup slot"); + assertTrue( + hashes.add(hash), "two known tags share a bucket hash: " + KnownTagCodec.nameOf(id)); + } + } + + @Test + void aSlottedKnownTagIsBucketedBySlot() throws IllegalAccessException { + for (long id : knownTagIds()) { + int slot = KnownTagCodec.slotOf(id); + if (slot == KnownTagCodec.NO_SLOT) { + continue; + } + // trace-level tags reuse the span-level slot numbers, so they move to the other half + int expected = (slot ^ (KnownTagCodec.isTraceLevel(id) ? 8 : 0)) & BUCKET_MASK; + assertEquals(expected, TagMap.Entry.bucketHash(id) & BUCKET_MASK, KnownTagCodec.nameOf(id)); + } + } + + @Test + void tagsWithoutASlotAndCustomTagsKeepTheirHashes() { + long noSlot = ((long) 5 << 48) | ((long) KnownTagCodec.NO_SLOT << 32); + assertEquals((int) (noSlot >>> 48) ^ (int) noSlot, TagMap.Entry.bucketHash(noSlot)); + + long custom = TagMap.Entry.customHash("my.custom.tag"); + assertEquals((int) custom == 0 ? 0xDD06 : (int) custom, TagMap.Entry.bucketHash(custom)); + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java b/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java index b8764472847..261422c2732 100644 --- a/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java +++ b/internal-api/src/test/java/datadog/trace/api/TagMapNullToleranceTest.java @@ -6,12 +6,16 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import java.util.HashMap; +import java.util.Map; import org.junit.jupiter.api.Test; /** * Pins the Entry-pathway null-tolerance contract: {@code create(...)} returns null for a null or * empty value, and the {@code Entry} sinks ({@code set(Entry)} / {@code getAndSet(Entry)}) treat - * null as a no-op -- so a null/empty value flows through as "no tag" without any caller guarding. + * null as a no-op -- so a null/empty value flows through as "no tag" without any caller guarding. A + * null value given to a setter removes the tag, as {@code setTag(tag, null)} does on a span. */ class TagMapNullToleranceTest { @@ -63,4 +67,55 @@ void nullOrEmptyFlowsThroughTheEntryPathwayAsNoTag() { assertEquals(1, map.size()); assertTrue(map.containsKey("present")); } + + @Test + void setNullRemovesAKnownOrCustomTag() { + TagMap map = TagMap.create(); + map.set(Tags.COMPONENT, "okhttp"); + map.set("custom.tag", "v"); + + map.set(Tags.COMPONENT, (Object) null); + map.set("custom.tag", (CharSequence) null); + assertEquals(0, map.size()); + assertFalse(map.containsKey(Tags.COMPONENT)); + assertFalse(map.containsKey("custom.tag")); + } + + @Test + void setNullByIdRemovesTheTag() { + TagMap map = TagMap.create(); + map.set(KnownTags.COMPONENT_ID, "okhttp"); + map.set(KnownTags.COMPONENT_ID, (Object) null); + assertNull(map.getObject(Tags.COMPONENT)); + + map.set(KnownTags.COMPONENT_ID, "okhttp"); + map.set(KnownTags.COMPONENT_ID, (CharSequence) null); + assertEquals(0, map.size()); + } + + @Test + void putAndGetAndSetNullRemoveTheTagAndReturnItsPriorValue() { + TagMap map = TagMap.create(); + map.set("custom.tag", "v"); + assertEquals("v", map.put("custom.tag", null)); + assertFalse(map.containsKey("custom.tag")); + + map.set(Tags.COMPONENT, "okhttp"); + assertEquals("okhttp", map.getAndSet(Tags.COMPONENT, (Object) null).objectValue()); + assertNull(map.getAndSet(Tags.COMPONENT, (CharSequence) null), "already removed"); + assertEquals(0, map.size()); + } + + @Test + void putAllWithANullValueLeavesNoTag() { + Map source = new HashMap<>(); + source.put("custom.tag", null); + source.put(Tags.COMPONENT, null); + source.put("present", "v"); + + TagMap map = TagMap.fromMap(source); + assertEquals(1, map.size()); + assertFalse(map.containsKey("custom.tag")); + assertFalse(map.containsKey(Tags.COMPONENT)); + } } diff --git a/internal-api/src/test/java/datadog/trace/api/endpoint/EndpointResolverTest.java b/internal-api/src/test/java/datadog/trace/api/endpoint/EndpointResolverTest.java index 10d6ad5b940..eeec260b9eb 100644 --- a/internal-api/src/test/java/datadog/trace/api/endpoint/EndpointResolverTest.java +++ b/internal-api/src/test/java/datadog/trace/api/endpoint/EndpointResolverTest.java @@ -6,8 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.HashMap; -import java.util.Map; +import datadog.trace.api.TagMap; import org.junit.jupiter.api.Test; import org.tabletest.junit.TableTest; @@ -47,7 +46,7 @@ void computeEndpointDelegatesToEndpointSimplifier(String input, String expected) @Test void disabledResolverReturnsNull() { EndpointResolver resolver = new EndpointResolver(false, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, "/users", "http://example.com/users/123"); @@ -59,7 +58,7 @@ void disabledResolverReturnsNull() { @Test void alwaysSimplifiedEndpointComputesFromUrlEvenWithValidRoute() { EndpointResolver resolver = new EndpointResolver(true, true); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, "/users", "http://example.com/users/123"); @@ -71,7 +70,7 @@ void alwaysSimplifiedEndpointComputesFromUrlEvenWithValidRoute() { @Test void usesEligibleRouteWithoutTagging() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, "/api/v1/users", "http://example.com/users/123"); @@ -91,7 +90,7 @@ void usesEligibleRouteWithoutTagging() { void computesEndpointAndTagsWhenRouteIsIneligible( String ineligibleRoute, String url, String expectedEndpoint) { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, ineligibleRoute, url); @@ -115,7 +114,7 @@ void isAlwaysSimplifiedEndpointReturnsConfigurationValue() { @Test void realWorldScenarioServiceWithRoute() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); // framework provides accurate route String result = @@ -132,7 +131,7 @@ void realWorldScenarioServiceWithRoute() { @Test void realWorldScenarioProxyWithoutRoute() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); // no route available (tracer in proxy) String result = @@ -147,7 +146,7 @@ void realWorldScenarioProxyWithoutRoute() { @Test void realWorldScenarioBadInstrumentationWithCatchAll() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); // instrumentation provides unhelpful catch-all route String result = @@ -161,7 +160,7 @@ void realWorldScenarioBadInstrumentationWithCatchAll() { @Test void realWorldScenarioTestingWithAlwaysSimplified() { EndpointResolver resolver = new EndpointResolver(true, true); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); // testing mode to compare with backend inference String result = @@ -176,7 +175,7 @@ void realWorldScenarioTestingWithAlwaysSimplified() { @Test void edgeCaseMalformedUrlReturnsDefaultEndpoint() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, null, "not-a-url"); @@ -189,7 +188,7 @@ void edgeCaseMalformedUrlReturnsDefaultEndpoint() { @Test void edgeCaseBothRouteAndUrlMissing() { EndpointResolver resolver = new EndpointResolver(true, false); - Map unsafeTags = new HashMap<>(); + TagMap unsafeTags = TagMap.create(); String result = resolver.resolveEndpoint(unsafeTags, null, null); diff --git a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemanticsTest.java b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemanticsTest.java index bfbf0a748fe..ea6ec759ffa 100644 --- a/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemanticsTest.java +++ b/internal-api/src/test/java/datadog/trace/bootstrap/instrumentation/api/OtelHttpSemanticsTest.java @@ -13,6 +13,8 @@ import datadog.trace.api.DDTags; import datadog.trace.api.KnownTags; +import datadog.trace.api.TagMap; +import datadog.trace.api.TagStore; import java.net.URI; import java.util.HashMap; import java.util.Map; @@ -79,6 +81,26 @@ void urlOrPathFallsBackOnlyWhenUrlIsAbsent(String httpUrl, String path, String e assertEquals(expected, OtelHttpSemantics.urlOrPath(tags)); } + /** From a span's tags as stored, it answers as it does from the same tags as a map. */ + @TableTest({ + "Scenario | Http URL | URL path ", + "No URL or path | | ", + "URL only | https://example.com/full | ", + "Path only | | /orders ", + "Prefer URL | https://example.com/full | /different", + "Empty URL | '' | /orders ", + "Empty path | | '' " + }) + void urlOrPathFromTagStoreMatchesMap(String httpUrl, String path) { + TagMap tags = TagMap.create(); + tags.store(KnownTags.HTTP_URL_ID, httpUrl); + tags.store(KnownTags.URL_PATH_ID, path); + + assertEquals( + OtelHttpSemantics.urlOrPath((Map) tags), + OtelHttpSemantics.urlOrPath((TagStore) tags)); + } + @Test void urlOrPathDoesNotRenderLazyUrl() { URIUtils.LazyUrl url = URIUtils.lazyValidURL("https", "example.com", 443, "/orders"); diff --git a/tag-conventions.yaml b/tag-conventions.yaml index cef70e92c0c..9b925e13dd0 100644 --- a/tag-conventions.yaml +++ b/tag-conventions.yaml @@ -88,6 +88,7 @@ trace_level: - { dd-name: _dd.trace_span_attribute_schema, type: int, required: required } - { dd-name: _dd.profiling.ctx, type: string, required: optional } # once the profiling context integration is available - { dd-name: process_id, type: long, required: required } + - { dd-name: _dd.agent_psr, type: double, required: optional } # the agent-provided sampling rate the sampler applied - { dd-name: ai_guard.event, type: boolean, required: optional } # when the trace holds an AI Guard evaluation span span_types: