diff --git a/components/context/context-api/build.gradle.kts b/components/context/context-api/build.gradle.kts new file mode 100644 index 00000000000..b8314ee7f33 --- /dev/null +++ b/components/context/context-api/build.gradle.kts @@ -0,0 +1 @@ +apply(from = "$rootDir/gradle/java.gradle") diff --git a/components/context/context-api/src/main/java/datadog/context/Context.java b/components/context/context-api/src/main/java/datadog/context/Context.java new file mode 100644 index 00000000000..bdde59bbe17 --- /dev/null +++ b/components/context/context-api/src/main/java/datadog/context/Context.java @@ -0,0 +1,77 @@ +package datadog.context; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +// TODO Javadoc +/** + * An immutable context key-value store. It can store any values but keys are unique. Setting a new + * value to an existing key will replace the existing value. + */ +public interface Context { + /** Retrieves the current context. */ + static @Nonnull Context current() { + return ContextStorage.get().current(); + } + + /** + * Retrieves the context bound to the given object. + * + * @param bearer The object to retrieve context from. + * @return The bound context, {@code null} if no context bound to the object. + */ + static @Nullable Context from(@Nonnull Object bearer) { + return ContextBinder.get().retrieveFrom(bearer); + } + + /** + * Creates an empty context. + * + * @return An empty context. + */ + static @Nonnull Context empty() { + return ContextStorage.get().empty(); + } + + /** + * Retrieves the value associated to the given key, {@code null} if there is no value for the key. + * + * @param key The key to get the associated value. + * @param The value type. + * @return The value associated to the given key, {@code null} if there is no value for the key. + */ + @Nullable V get(@Nonnull ContextKey key); + + /** + * Create a new context with given key value set, in addition to any existing values. + * + * @param key The key associated to the value. + * @param value The value to store, or {@code null} to remove the value associated to the given + * key if any./ + * @param The value type. + * @return A new context with the given key value set, in addition to any existing values. + */ + @Nonnull Context with(@Nonnull ContextKey key, @Nullable V value); + + // default Context without(ContextKey key) { + // return with(key, null); + // } + + /** + * Makes the context current. + * + * @return The scope associated to the current execution. + */ + default @Nonnull ContextScope makeCurrent() { + return ContextStorage.get().attach(this); + } + + /** + * Binds a context to an object. + * + * @param bearer The object to bear the given context. + */ + default void attachTo(@Nonnull Object bearer) { + ContextBinder.get().attach(this, bearer); + } +} diff --git a/components/context/context-api/src/main/java/datadog/context/ContextBinder.java b/components/context/context-api/src/main/java/datadog/context/ContextBinder.java new file mode 100644 index 00000000000..6ce382574bd --- /dev/null +++ b/components/context/context-api/src/main/java/datadog/context/ContextBinder.java @@ -0,0 +1,17 @@ +package datadog.context; + +import static datadog.context.Loaders.CONTEXT_BINDER; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public interface ContextBinder { + static @Nonnull ContextBinder get() { + return CONTEXT_BINDER; + } + + boolean attach(@Nonnull Context context, @Nonnull Object bearer); + + @Nullable + Context retrieveFrom(@Nonnull Object bearer); +} diff --git a/components/context/context-api/src/main/java/datadog/context/ContextKey.java b/components/context/context-api/src/main/java/datadog/context/ContextKey.java new file mode 100644 index 00000000000..56f91809ec9 --- /dev/null +++ b/components/context/context-api/src/main/java/datadog/context/ContextKey.java @@ -0,0 +1,44 @@ +package datadog.context; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The key to store and retrieve values from {@link Context} key-value storage. + * + * @param The type of the value to store. + */ +public class ContextKey { + private static final AtomicInteger INDEX_GENERATOR = new AtomicInteger(0); + private final String name; + private final int index; + + private ContextKey(String name) { + this.name = name; + this.index = INDEX_GENERATOR.getAndIncrement(); + } + + /** + * Create a key for ScopedContext key-value store. + * + * @param name A display name for the key (debug/log purpose only). + * @param The type of the value to store. + * @return The key instance to store and retrieve value from ScopedContext key-value storage. + */ + public static ContextKey named(String name) { + return new ContextKey(name); + } + + /** + * Get the context store index for this key. + * + * @return The context store index for this key. + */ + int index() { + return this.index; + } + + @Override + public String toString() { + return this.name; + } +} diff --git a/components/context/context-api/src/main/java/datadog/context/ContextScope.java b/components/context/context-api/src/main/java/datadog/context/ContextScope.java new file mode 100644 index 00000000000..263aeb6d1bd --- /dev/null +++ b/components/context/context-api/src/main/java/datadog/context/ContextScope.java @@ -0,0 +1,19 @@ +package datadog.context; + +/** + * A scope representing an attached context for an execution unit. Closing the scope will detach the + * context. + * + *

* Scopes are intended to be used with {@code try-with-resources} block: + * + *

{@code
+ * try (Scope ignored = span.makeCurrent()) {
+ *   // Execution unit
+ * }
+ * }
+ */ +@FunctionalInterface +public interface ContextScope extends AutoCloseable { + @Override + void close(); // Should not throw exception +} diff --git a/components/context/context-api/src/main/java/datadog/context/ContextStorage.java b/components/context/context-api/src/main/java/datadog/context/ContextStorage.java new file mode 100644 index 00000000000..b99a0fa6be3 --- /dev/null +++ b/components/context/context-api/src/main/java/datadog/context/ContextStorage.java @@ -0,0 +1,15 @@ +package datadog.context; + +import static datadog.context.Loaders.CONTEXT_STORAGE; + +public interface ContextStorage { + static ContextStorage get() { + return CONTEXT_STORAGE; + } + + Context empty(); + + Context current(); + + ContextScope attach(Context context); +} diff --git a/components/context/context-api/src/main/java/datadog/context/Loaders.java b/components/context/context-api/src/main/java/datadog/context/Loaders.java new file mode 100644 index 00000000000..409f1ff31f1 --- /dev/null +++ b/components/context/context-api/src/main/java/datadog/context/Loaders.java @@ -0,0 +1,85 @@ +package datadog.context; + +import static java.util.Comparator.comparingInt; + +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.ServiceLoader; +import javax.annotation.Nonnull; + +final class Loaders { + static final ContextStorage CONTEXT_STORAGE = loadContextStorage(); + static final ContextBinder CONTEXT_BINDER = loadContextBinder(); + + private static ContextBinder loadContextBinder() { + // Load all context binder providers + ServiceLoader serviceLoader = + ServiceLoader.load(ContextBinderProvider.class); + List providers = new LinkedList<>(); + for (ContextBinderProvider provider : serviceLoader) { + providers.add(provider); + } + // Check found providers + if (providers.isEmpty()) { + throw new IllegalStateException("No ContextBinder implementation found"); + } else if (providers.size() == 1) { + // Directly return the only binder available + return providers.get(0).getContextBinder(); + } else { + // Return a compound provider respecting their priority + providers.sort(comparingInt(ContextBinderProvider::priority)); + ContextBinder[] binders = + providers.stream() + .map(ContextBinderProvider::getContextBinder) + .toArray(ContextBinder[]::new); + return new CompoundContextBinder(binders); + } + } + + static ContextStorage loadContextStorage() { + ServiceLoader serviceLoader = ServiceLoader.load(ContextStorage.class); + Iterator iterator = serviceLoader.iterator(); + if (iterator.hasNext()) { + return iterator.next(); + } else { + throw new IllegalStateException("No ContextStorage implementation found"); + } + } + + public interface ContextBinderProvider { + ContextBinder getContextBinder(); + + int priority(); + } + + private static class CompoundContextBinder implements ContextBinder { + private final ContextBinder[] contextBinders; + + private CompoundContextBinder(ContextBinder[] contextBinders) { + this.contextBinders = contextBinders; + } + + @Override + public boolean attach(@Nonnull Context context, @Nonnull Object bearer) { + for (ContextBinder contextBinder : this.contextBinders) { + if (contextBinder.attach(context, bearer)) { + return true; + } + } + return false; + } + + @Override + public Context retrieveFrom(@Nonnull Object bearer) { + Context value = null; + for (ContextBinder contextBinder : this.contextBinders) { + value = contextBinder.retrieveFrom(bearer); + if (value != null) { + return value; + } + } + return null; + } + } +} diff --git a/components/context/context-core/build.gradle.kts b/components/context/context-core/build.gradle.kts new file mode 100644 index 00000000000..f155c9ec8e9 --- /dev/null +++ b/components/context/context-core/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + `java-library` +} + +apply(from = "$rootDir/gradle/java.gradle") + +dependencies { + api(project(":components:context:context-api")) + implementation(libs.slf4j) +} diff --git a/components/context/context-core/src/main/java/datadog/context/ArrayBasedContext.java b/components/context/context-core/src/main/java/datadog/context/ArrayBasedContext.java new file mode 100644 index 00000000000..3e40d5b0bb5 --- /dev/null +++ b/components/context/context-core/src/main/java/datadog/context/ArrayBasedContext.java @@ -0,0 +1,70 @@ +package datadog.context; + +import static java.lang.Math.max; +import static java.util.Arrays.copyOfRange; + +import java.util.Arrays; +import java.util.Map; +import javax.annotation.Nonnull; + +/** An array-based {@link Context} implementation. */ +public class ArrayBasedContext implements Context { + private static final ArrayBasedContext EMPTY = new ArrayBasedContext(new Object[0]); + /** The generic store. Values are indexed by their {@link ContextKey#index()}. */ + private final Object[] store; + + private ArrayBasedContext(Object[] store) { + this.store = store; + } + + /** + * Get an empty context. + * + * @return An empty context. + */ + public static ArrayBasedContext empty() { + return EMPTY; + } + + // TODO DOCUMENT + public static ArrayBasedContext fromMap(Map, Object> content) { + if (content.isEmpty()) { + return empty(); + } + int length = content.keySet().stream().mapToInt(ContextKey::index).max().orElse(0) + 1; + Object[] store = new Object[length]; + content.forEach((key, value) -> store[key.index()] = value); + return new ArrayBasedContext(store); + } + + @SuppressWarnings("unchecked") + @Override + public T get(@Nonnull ContextKey key) { + return key != null && key.index() < this.store.length ? (T) this.store[key.index()] : null; + } + + @Nonnull + @Override + public ArrayBasedContext with(@Nonnull ContextKey key, T value) { + if (key == null) { + return this; + } + Object[] newStore = copyOfRange(this.store, 0, max(this.store.length, key.index() + 1)); + newStore[key.index()] = value; + return new ArrayBasedContext(newStore); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ArrayBasedContext that = (ArrayBasedContext) o; + return Arrays.equals(this.store, that.store); + } + + @Override + public int hashCode() { + return Arrays.hashCode(this.store); + } +} diff --git a/components/context/context-core/src/main/java/datadog/context/DefaultContextBinder.java b/components/context/context-core/src/main/java/datadog/context/DefaultContextBinder.java new file mode 100644 index 00000000000..b7e0697cb98 --- /dev/null +++ b/components/context/context-core/src/main/java/datadog/context/DefaultContextBinder.java @@ -0,0 +1,32 @@ +package datadog.context; + +import java.util.Map; +import java.util.WeakHashMap; +import javax.annotation.Nonnull; + +public class DefaultContextBinder implements ContextBinder { + private final Map bindings = new WeakHashMap<>(); + + @Override + public boolean attach(@Nonnull Context context, @Nonnull Object bearer) { + this.bindings.put(bearer, context); + return true; + } + + @Override + public Context retrieveFrom(@Nonnull Object bearer) { + return this.bindings.get(bearer); + } + + public static class Provider implements Loaders.ContextBinderProvider { + @Override + public ContextBinder getContextBinder() { + return new DefaultContextBinder(); + } + + @Override + public int priority() { + return 0; + } + } +} diff --git a/components/context/context-core/src/main/java/datadog/context/DefaultContextStorage.java b/components/context/context-core/src/main/java/datadog/context/DefaultContextStorage.java new file mode 100644 index 00000000000..b4ee0a35002 --- /dev/null +++ b/components/context/context-core/src/main/java/datadog/context/DefaultContextStorage.java @@ -0,0 +1,68 @@ +package datadog.context; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DefaultContextStorage implements ContextStorage { + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultContextScope.class); + + private static final ThreadLocal THREAD_LOCAL_STORAGE = + ThreadLocal.withInitial(ArrayBasedContext::empty); + + @Override + public Context empty() { + return ArrayBasedContext.empty(); + } + + @Override + public Context current() { + return null; + } + + @Override + public ContextScope attach(Context context) { + // Check invalid argument + if (context == null) { + return NoopContextScope.INSTANCE; + } + // Check already attached context + Context current = current(); + if (current == context) { + return NoopContextScope.INSTANCE; + } + // Set new context and create related scope + THREAD_LOCAL_STORAGE.set(context); + return new DefaultContextScope(current, context); + } + + private static class DefaultContextScope implements ContextScope { + private final Context previous; + private final Context current; + private boolean closed; + + private DefaultContextScope(Context previous, Context current) { + this.previous = previous; + this.current = current; + this.closed = false; + } + + @Override + public void close() { + // Ensure if closing the current scope + if (this.closed || Context.current() != this.current) { + LOGGER.debug("Attempt to close context scope that is not current"); + return; + } + // Close the scope and restore previous context + this.closed = true; + THREAD_LOCAL_STORAGE.set(this.previous); + } + } + + private enum NoopContextScope implements ContextScope { + INSTANCE; + + @Override + public void close() {} + } +} diff --git a/components/context/context-core/src/main/resources/META-INF/services/datadog.context.ContextStorage b/components/context/context-core/src/main/resources/META-INF/services/datadog.context.ContextStorage new file mode 100644 index 00000000000..03afb01d282 --- /dev/null +++ b/components/context/context-core/src/main/resources/META-INF/services/datadog.context.ContextStorage @@ -0,0 +1 @@ +datadog.context.DefaultContextStorage diff --git a/components/context/context-core/src/main/resources/META-INF/services/datadog.context.Loaders.ContextBinderProvider b/components/context/context-core/src/main/resources/META-INF/services/datadog.context.Loaders.ContextBinderProvider new file mode 100644 index 00000000000..3572d00179e --- /dev/null +++ b/components/context/context-core/src/main/resources/META-INF/services/datadog.context.Loaders.ContextBinderProvider @@ -0,0 +1 @@ +datadog.context.DefaultContextBinder$Provider diff --git a/settings.gradle b/settings.gradle index 53384c4822a..0d401dc37a6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -66,6 +66,8 @@ include ':dd-java-agent:agent-otel:otel-shim' include ':dd-java-agent:agent-otel:otel-tooling' include ':communication' +include ':components:context:context-api' +include ':components:context:context-core' include ':telemetry' include ':remote-config:remote-config-api' include ':remote-config:remote-config-core'