From df49024471836cfcb59a8f0531209c659f377f4b Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Mon, 15 Sep 2025 10:58:02 +0200 Subject: [PATCH 1/7] add bench Signed-off-by: christian.lutnik --- .../sdk/benchmark/AllocationBenchmark.java | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java index 5bc89d03d..eea91632e 100644 --- a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java +++ b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java @@ -32,9 +32,9 @@ public class AllocationBenchmark { // 10K iterations works well with Xmx1024m (we don't want to run out of memory) private static final int ITERATIONS = 10000; - @Benchmark - @BenchmarkMode(Mode.SingleShotTime) - @Fork(jvmArgsAppend = {"-Xmx1024m", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseEpsilonGC"}) + //@Benchmark + //@BenchmarkMode(Mode.SingleShotTime) + //@Fork(jvmArgsAppend = {"-Xmx1024m", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseEpsilonGC"}) public void run() { OpenFeatureAPI.getInstance().setProviderAndWait(new NoOpProvider()); @@ -67,4 +67,54 @@ public Optional before(HookContext ctx, Map globalAttrs = new HashMap<>(); + globalAttrs.put("global", new Value(1)); + EvaluationContext globalContext = new ImmutableContext(globalAttrs); + OpenFeatureAPI.getInstance().setEvaluationContext(globalContext); + + Client client = OpenFeatureAPI.getInstance().getClient(); + + Map clientAttrs = new HashMap<>(); + clientAttrs.put("client", new Value(2)); + client.setEvaluationContext(new ImmutableContext(clientAttrs)); + + Map transactionAttr = new HashMap<>(); + transactionAttr.put("trans", new Value(4)); + + Map transactionAttr2 = new HashMap<>(); + transactionAttr2.put("trans2", new Value(5)); + + Map invocationAttrs = new HashMap<>(); + invocationAttrs.put("invoke", new Value(3)); + EvaluationContext invocationContext = new ImmutableContext(invocationAttrs); + + for (int i = 0; i < 100; i++) { + OpenFeatureAPI.getInstance().setTransactionContext(new ImmutableContext(transactionAttr)); + + for (int j = 0; j < 10; j++) { + client.getBooleanValue(BOOLEAN_FLAG_KEY, false); + client.getStringValue(STRING_FLAG_KEY, "default"); + client.getIntegerValue(INT_FLAG_KEY, 0); + client.getDoubleValue(FLOAT_FLAG_KEY, 0.0); + client.getObjectDetails(OBJECT_FLAG_KEY, new Value(new ImmutableStructure()), invocationContext); + } + + OpenFeatureAPI.getInstance().setTransactionContext(new ImmutableContext(transactionAttr2)); + + for (int j = 0; j < 10; j++) { + client.getBooleanValue(BOOLEAN_FLAG_KEY, false); + client.getStringValue(STRING_FLAG_KEY, "default"); + client.getIntegerValue(INT_FLAG_KEY, 0); + client.getDoubleValue(FLOAT_FLAG_KEY, 0.0); + client.getObjectDetails(OBJECT_FLAG_KEY, new Value(new ImmutableStructure()), invocationContext); + } + } + } } From ea3990dc9abe711cfa44efec7312b29dc818b850 Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Mon, 15 Sep 2025 11:23:33 +0200 Subject: [PATCH 2/7] fix bench Signed-off-by: christian.lutnik --- .../dev/openfeature/sdk/benchmark/AllocationBenchmark.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java index eea91632e..31f817118 100644 --- a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java +++ b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java @@ -14,6 +14,7 @@ import dev.openfeature.sdk.ImmutableStructure; import dev.openfeature.sdk.NoOpProvider; import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator; import dev.openfeature.sdk.Value; import java.util.HashMap; import java.util.Map; @@ -74,6 +75,8 @@ public Optional before(HookContext ctx, Map globalAttrs = new HashMap<>(); globalAttrs.put("global", new Value(1)); EvaluationContext globalContext = new ImmutableContext(globalAttrs); From 9d49b91e464f64ece7d74b95c907620845314c2e Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Wed, 15 Oct 2025 15:29:28 +0200 Subject: [PATCH 3/7] merge master Signed-off-by: christian.lutnik --- .../openfeature/sdk/benchmark/AllocationBenchmark.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java index d7668b25d..e1a6f489c 100644 --- a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java +++ b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java @@ -23,6 +23,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +import org.junit.jupiter.api.Test; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Fork; @@ -37,9 +38,9 @@ public class AllocationBenchmark { // 10K iterations works well with Xmx1024m (we don't want to run out of memory) private static final int ITERATIONS = 10000; - //@Benchmark - //@BenchmarkMode(Mode.SingleShotTime) - //@Fork(jvmArgsAppend = {"-Xmx1024m", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseEpsilonGC"}) + // @Benchmark + // @BenchmarkMode(Mode.SingleShotTime) + // @Fork(jvmArgsAppend = {"-Xmx1024m", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseEpsilonGC"}) public void run() { OpenFeatureAPI.getInstance().setProviderAndWait(new NoOpProvider()); @@ -100,6 +101,7 @@ public Optional before(HookContext ctx, Map Date: Wed, 12 Nov 2025 10:26:22 +0100 Subject: [PATCH 4/7] improve benchmark Signed-off-by: christian.lutnik --- .../sdk/benchmark/AllocationBenchmark.java | 74 ++++++------------- .../benchmark/AllocationBenchmarkState.java | 50 +++++++++++++ 2 files changed, 74 insertions(+), 50 deletions(-) create mode 100644 src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmarkState.java diff --git a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java index e1a6f489c..9fdfc5629 100644 --- a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java +++ b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java @@ -18,16 +18,15 @@ import dev.openfeature.sdk.ObjectHook; import dev.openfeature.sdk.OpenFeatureAPI; import dev.openfeature.sdk.StringHook; -import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator; import dev.openfeature.sdk.Value; import java.util.HashMap; import java.util.Map; import java.util.Optional; -import org.junit.jupiter.api.Test; import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.infra.Blackhole; /** * Runs a large volume of flag evaluations on a VM with 1G memory and GC @@ -99,55 +98,30 @@ public Optional before(HookContext ctx, Map globalAttrs = new HashMap<>(); - globalAttrs.put("global", new Value(1)); - EvaluationContext globalContext = new ImmutableContext(globalAttrs); - OpenFeatureAPI.getInstance().setEvaluationContext(globalContext); - - Client client = OpenFeatureAPI.getInstance().getClient(); - - Map clientAttrs = new HashMap<>(); - clientAttrs.put("client", new Value(2)); - client.setEvaluationContext(new ImmutableContext(clientAttrs)); - - Map transactionAttr = new HashMap<>(); - transactionAttr.put("trans", new Value(4)); - - Map transactionAttr2 = new HashMap<>(); - transactionAttr2.put("trans2", new Value(5)); - - Map invocationAttrs = new HashMap<>(); - invocationAttrs.put("invoke", new Value(3)); - EvaluationContext invocationContext = new ImmutableContext(invocationAttrs); - - for (int i = 0; i < 100; i++) { - OpenFeatureAPI.getInstance().setTransactionContext(new ImmutableContext(transactionAttr)); - - for (int j = 0; j < 10; j++) { - client.getBooleanValue(BOOLEAN_FLAG_KEY, false); - client.getStringValue(STRING_FLAG_KEY, "default"); - client.getIntegerValue(INT_FLAG_KEY, 0); - client.getDoubleValue(FLOAT_FLAG_KEY, 0.0); - client.getObjectDetails(OBJECT_FLAG_KEY, new Value(new ImmutableStructure()), invocationContext); - } + //@Test + public void context(Blackhole blackhole, AllocationBenchmarkState state) { + OpenFeatureAPI.getInstance().setTransactionContext(new ImmutableContext(state.transactionAttr)); + + for (int j = 0; j < 2; j++) { + blackhole.consume(state.client.getBooleanValue(BOOLEAN_FLAG_KEY, false)); + blackhole.consume(state.client.getStringValue(STRING_FLAG_KEY, "default")); + blackhole.consume(state.client.getIntegerValue(INT_FLAG_KEY, 0, state.invocationContext)); + blackhole.consume(state.client.getDoubleValue(FLOAT_FLAG_KEY, 0.0)); + blackhole.consume(state.client.getObjectDetails(OBJECT_FLAG_KEY, new Value(new ImmutableStructure()), + state.invocationContext)); + } - OpenFeatureAPI.getInstance().setTransactionContext(new ImmutableContext(transactionAttr2)); + OpenFeatureAPI.getInstance().setTransactionContext(new ImmutableContext(state.transactionAttr2)); - for (int j = 0; j < 10; j++) { - client.getBooleanValue(BOOLEAN_FLAG_KEY, false); - client.getStringValue(STRING_FLAG_KEY, "default"); - client.getIntegerValue(INT_FLAG_KEY, 0); - client.getDoubleValue(FLOAT_FLAG_KEY, 0.0); - client.getObjectDetails(OBJECT_FLAG_KEY, new Value(new ImmutableStructure()), invocationContext); - } + for (int j = 0; j < 2; j++) { + blackhole.consume(state.client.getBooleanValue(BOOLEAN_FLAG_KEY, false)); + blackhole.consume(state.client.getStringValue(STRING_FLAG_KEY, "default")); + blackhole.consume(state.client.getIntegerValue(INT_FLAG_KEY, 0, state.invocationContext)); + blackhole.consume(state.client.getDoubleValue(FLOAT_FLAG_KEY, 0.0)); + blackhole.consume(state.client.getObjectDetails(OBJECT_FLAG_KEY, new Value(new ImmutableStructure()), + state.invocationContext)); } } } diff --git a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmarkState.java b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmarkState.java new file mode 100644 index 000000000..56f62fce1 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmarkState.java @@ -0,0 +1,50 @@ +package dev.openfeature.sdk.benchmark; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.NoOpProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator; +import dev.openfeature.sdk.Value; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import java.util.HashMap; +import java.util.Map; + +@State(Scope.Benchmark) +public class AllocationBenchmarkState { + public final Client client; + public final Map transactionAttr; + public final Map transactionAttr2; + public final EvaluationContext invocationContext; + + public AllocationBenchmarkState(){ + long start = System.currentTimeMillis(); + OpenFeatureAPI.getInstance().setProviderAndWait(new NoOpProvider()); + OpenFeatureAPI.getInstance().setTransactionContextPropagator(new ThreadLocalTransactionContextPropagator()); + long end = System.currentTimeMillis(); + System.out.println("Setup time: " + (end - start) + "ms"); + Map globalAttrs = new HashMap<>(); + globalAttrs.put("global", new Value(1)); + EvaluationContext globalContext = new ImmutableContext(globalAttrs); + OpenFeatureAPI.getInstance().setEvaluationContext(globalContext); + + client = OpenFeatureAPI.getInstance().getClient(); + + Map clientAttrs = new HashMap<>(); + clientAttrs.put("client", new Value(2)); + client.setEvaluationContext(new ImmutableContext(clientAttrs)); + + transactionAttr = new HashMap<>(); + transactionAttr.put("trans", new Value(4)); + + transactionAttr2 = new HashMap<>(); + transactionAttr2.put("trans2", new Value(5)); + + Map invocationAttrs = new HashMap<>(); + invocationAttrs.put("invoke", new Value(3)); + invocationContext = new ImmutableContext(invocationAttrs); + + } +} From 2e9af46afa8f44916f2acdac03c30a6fb38ddcdb Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Wed, 12 Nov 2025 11:12:32 +0100 Subject: [PATCH 5/7] improve benchmark Signed-off-by: christian.lutnik --- .../sdk/benchmark/AllocationBenchmark.java | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java index 9fdfc5629..437292fa8 100644 --- a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java +++ b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java @@ -22,6 +22,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +import org.junit.jupiter.api.Test; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Scope; @@ -99,8 +100,7 @@ public Optional before(HookContext ctx, Map Date: Wed, 12 Nov 2025 17:01:06 +0100 Subject: [PATCH 6/7] layered context Signed-off-by: christian.lutnik --- .../java/dev/openfeature/sdk/HookSupport.java | 43 ++-- .../dev/openfeature/sdk/HookSupportData.java | 2 +- .../sdk/LayeredEvaluationContext.java | 184 ++++++++++++++++++ .../openfeature/sdk/OpenFeatureClient.java | 11 +- .../dev/openfeature/sdk/HookSpecTest.java | 2 +- .../dev/openfeature/sdk/HookSupportTest.java | 15 +- .../sdk/benchmark/AllocationBenchmark.java | 21 +- .../benchmark/AllocationBenchmarkState.java | 7 +- 8 files changed, 232 insertions(+), 53 deletions(-) create mode 100644 src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java diff --git a/src/main/java/dev/openfeature/sdk/HookSupport.java b/src/main/java/dev/openfeature/sdk/HookSupport.java index c7a7630da..31e30a04a 100644 --- a/src/main/java/dev/openfeature/sdk/HookSupport.java +++ b/src/main/java/dev/openfeature/sdk/HookSupport.java @@ -17,9 +17,9 @@ class HookSupport { * Sets the {@link Hook}-{@link HookContext}-{@link Pair} list in the given data object with {@link HookContext} * set to null. Filters hooks by supported {@link FlagValueType}. * - * @param hookSupportData the data object to modify - * @param hooks the hooks to set - * @param type the flag value type to filter unsupported hooks + * @param hookSupportData the data object to modify + * @param hooks the hooks to set + * @param type the flag value type to filter unsupported hooks */ public void setHooks(HookSupportData hookSupportData, List hooks, FlagValueType type) { List> hookContextPairs = new ArrayList<>(); @@ -35,35 +35,20 @@ public void setHooks(HookSupportData hookSupportData, List hooks, FlagValu * Creates & sets a {@link HookContext} for every {@link Hook}-{@link HookContext}-{@link Pair} * in the given data object with a new {@link HookData} instance. * - * @param hookSupportData the data object to modify - * @param sharedContext the shared context from which the new {@link HookContext} is created + * @param hookSupportData the data object to modify + * @param sharedContext the shared context from which the new {@link HookContext} is created */ - public void setHookContexts(HookSupportData hookSupportData, SharedHookContext sharedContext) { + public void setHookContexts( + HookSupportData hookSupportData, + SharedHookContext sharedContext, + LayeredEvaluationContext evaluationContext) { for (int i = 0; i < hookSupportData.hooks.size(); i++) { Pair hookContextPair = hookSupportData.hooks.get(i); - HookContext curHookContext = sharedContext.hookContextFor(null, new DefaultHookData()); + HookContext curHookContext = sharedContext.hookContextFor(evaluationContext, new DefaultHookData()); hookContextPair.setValue(curHookContext); } } - /** - * Updates the evaluation context in the given data object's eval context and each hooks eval context. - * - * @param hookSupportData the data object to modify - * @param evaluationContext the new context to set - */ - public void updateEvaluationContext(HookSupportData hookSupportData, EvaluationContext evaluationContext) { - hookSupportData.evaluationContext = evaluationContext; - if (hookSupportData.hooks != null) { - for (Pair hookContextPair : hookSupportData.hooks) { - var curHookContext = hookContextPair.getValue(); - if (curHookContext != null) { - curHookContext.setCtx(evaluationContext); - } - } - } - } - public void executeBeforeHooks(HookSupportData data) { // These traverse backwards from normal. List> reversedHooks = new ArrayList<>(data.getHooks()); @@ -77,8 +62,12 @@ public void executeBeforeHooks(HookSupportData data) { hook.before(hookContext, data.getHints())) .orElse(Optional.empty()); if (returnedEvalContext.isPresent()) { - // update shared evaluation context for all hooks - updateEvaluationContext(data, data.getEvaluationContext().merge(returnedEvalContext.get())); + var returnedContext = returnedEvalContext.get(); + if (returnedContext.isEmpty()) { + continue; + } + + data.evaluationContext.putAllHookContexts(returnedContext.asMap()); } } } diff --git a/src/main/java/dev/openfeature/sdk/HookSupportData.java b/src/main/java/dev/openfeature/sdk/HookSupportData.java index 2d3346ba1..174702ea2 100644 --- a/src/main/java/dev/openfeature/sdk/HookSupportData.java +++ b/src/main/java/dev/openfeature/sdk/HookSupportData.java @@ -11,7 +11,7 @@ class HookSupportData { List> hooks; - EvaluationContext evaluationContext; + LayeredEvaluationContext evaluationContext; Map hints; HookSupportData() {} diff --git a/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java b/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java new file mode 100644 index 000000000..e27392c0b --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java @@ -0,0 +1,184 @@ +package dev.openfeature.sdk; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * LayeredEvaluationContext implements EvaluationContext by layering multiple contexts: + * API-level, Transaction-level, Client-level, Invocation-level, and Hook-level. + * The contexts are checked in that order for values, with Hook-level having the highest precedence. + */ +public class LayeredEvaluationContext implements EvaluationContext { + private final EvaluationContext apiContext; + private final EvaluationContext transactionContext; + private final EvaluationContext clientContext; + private final EvaluationContext invocationContext; + private final HashMap hookContext = new HashMap<>(); + private final String targetingKey; + + private Set keySet = null; + + /** + * Constructor for LayeredEvaluationContext. + */ + public LayeredEvaluationContext( + EvaluationContext apiContext, + EvaluationContext transactionContext, + EvaluationContext clientContext, + EvaluationContext invocationContext) { + this.apiContext = apiContext; + this.transactionContext = transactionContext; + this.clientContext = clientContext; + this.invocationContext = invocationContext; + + if (invocationContext != null && invocationContext.getTargetingKey() != null) { + this.targetingKey = invocationContext.getTargetingKey(); + } else if (clientContext != null && clientContext.getTargetingKey() != null) { + this.targetingKey = clientContext.getTargetingKey(); + } else if (transactionContext != null && transactionContext.getTargetingKey() != null) { + this.targetingKey = transactionContext.getTargetingKey(); + } else if (apiContext != null && apiContext.getTargetingKey() != null) { + this.targetingKey = apiContext.getTargetingKey(); + } else { + this.targetingKey = null; + } + } + + @Override + public String getTargetingKey() { + return targetingKey; + } + + @Override + public EvaluationContext merge(EvaluationContext overridingContext) { + return null; + } + + @Override + public boolean isEmpty() { + return hookContext.isEmpty() + && (invocationContext == null || invocationContext.isEmpty()) + && (clientContext == null || clientContext.isEmpty()) + && (transactionContext == null || transactionContext.isEmpty()) + && (apiContext == null || apiContext.isEmpty()); + } + + @Override + public Set keySet() { + return new HashSet<>(ensureKeySet()); + } + + private Set ensureKeySet() { + if (this.keySet != null) { + return this.keySet; + } + + var keys = new HashSet<>(hookContext.keySet()); + + if (invocationContext != null) { + keys.addAll(invocationContext.keySet()); + } + if (clientContext != null) { + keys.addAll(clientContext.keySet()); + } + if (transactionContext != null) { + keys.addAll(transactionContext.keySet()); + } + if (apiContext != null) { + keys.addAll(apiContext.keySet()); + } + this.keySet = keys; + return keys; + } + + private Value getFromContext(EvaluationContext context, String key) { + if (context != null) { + return context.getValue(key); + } + return null; + } + + private Value getFromContext(HashMap context, String key) { + if (context != null) { + return context.get(key); + } + return null; + } + + @Override + public Value getValue(String key) { + var hookValue = getFromContext(hookContext, key); + if (hookValue != null) { + return hookValue; + } + var invocationValue = getFromContext(invocationContext, key); + if (invocationValue != null) { + return invocationValue; + } + var clientValue = getFromContext(clientContext, key); + if (clientValue != null) { + return clientValue; + } + var transactionValue = getFromContext(transactionContext, key); + if (transactionValue != null) { + return transactionValue; + } + return getFromContext(apiContext, key); + } + + @Override + public Map asMap() { + var keySet = ensureKeySet(); + var keys = keySet.size(); + if (keys == 0) { + return new HashMap<>(1); + } + var map = new HashMap(keys); + + for (String key : keySet) { + map.put(key, getValue(key)); + } + return map; + } + + @Override + public Map asUnmodifiableMap() { + var keySet = ensureKeySet(); + var keys = keySet.size(); + if (keys == 0) { + return Collections.emptyMap(); + } + var map = new HashMap(keys); + + for (String key : keySet) { + map.put(key, getValue(key)); + } + return Collections.unmodifiableMap(map); + } + + @Override + public Map asObjectMap() { + var keySet = ensureKeySet(); + var keys = keySet.size(); + if (keys == 0) { + return new HashMap<>(1); + } + var map = new HashMap(keys); + + for (String key : keySet) { + map.put(key, convertValue(getValue(key))); + } + return map; + } + + public void putHookContext(String key, Value value) { + this.hookContext.put(key, value); + } + + public void putAllHookContexts(Map context) { + this.hookContext.putAll(context); + } +} diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java index 614bc1e34..c5548b394 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java @@ -166,6 +166,12 @@ private FlagEvaluationDetails evaluateFlag( var flagOptions = ObjectUtils.defaultIfNull( options, () -> FlagEvaluationOptions.builder().build()); hookSupportData.hints = Collections.unmodifiableMap(flagOptions.getHookHints()); + var context = new LayeredEvaluationContext( + openfeatureApi.getEvaluationContext(), + openfeatureApi.getTransactionContext(), + evaluationContext.get(), + ctx); + hookSupportData.evaluationContext = context; try { final var stateManager = openfeatureApi.getFeatureProviderStateManager(this.domain); @@ -180,10 +186,7 @@ private FlagEvaluationDetails evaluateFlag( var sharedHookContext = new SharedHookContext(key, type, this.getMetadata(), provider.getMetadata(), defaultValue); - hookSupport.setHookContexts(hookSupportData, sharedHookContext); - - var evalContext = mergeEvaluationContext(ctx); - hookSupport.updateEvaluationContext(hookSupportData, evalContext); + hookSupport.setHookContexts(hookSupportData, sharedHookContext, context); hookSupport.executeBeforeHooks(hookSupportData); diff --git a/src/test/java/dev/openfeature/sdk/HookSpecTest.java b/src/test/java/dev/openfeature/sdk/HookSpecTest.java index 56d88dfba..94d6fb1a2 100644 --- a/src/test/java/dev/openfeature/sdk/HookSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/HookSpecTest.java @@ -721,7 +721,7 @@ void mergeHappensCorrectly() { invocationCtx, FlagEvaluationOptions.builder().hook(hook).build()); - ArgumentCaptor captor = ArgumentCaptor.forClass(ImmutableContext.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(LayeredEvaluationContext.class); verify(provider).getBooleanEvaluation(any(), any(), captor.capture()); EvaluationContext ec = captor.getValue(); assertEquals("works", ec.getValue("test").asString()); diff --git a/src/test/java/dev/openfeature/sdk/HookSupportTest.java b/src/test/java/dev/openfeature/sdk/HookSupportTest.java index b1bb70ba1..ef8dcc396 100644 --- a/src/test/java/dev/openfeature/sdk/HookSupportTest.java +++ b/src/test/java/dev/openfeature/sdk/HookSupportTest.java @@ -33,11 +33,12 @@ void shouldMergeEvaluationContextsOnBeforeHooksCorrectly() { when(hook1.before(any(), any())).thenReturn(Optional.of(evaluationContextWithValue("bla", "blubber"))); when(hook2.before(any(), any())).thenReturn(Optional.of(evaluationContextWithValue("foo", "bar"))); + var layered = new LayeredEvaluationContext(baseEvalContext, null, null, null); var sharedContext = getBaseHookContextForType(FlagValueType.STRING); var hookSupportData = new HookSupportData(); + hookSupportData.evaluationContext = layered; hookSupport.setHooks(hookSupportData, Arrays.asList(hook1, hook2), FlagValueType.STRING); - hookSupport.setHookContexts(hookSupportData, sharedContext); - hookSupport.updateEvaluationContext(hookSupportData, baseEvalContext); + hookSupport.setHookContexts(hookSupportData, sharedContext, layered); hookSupport.executeBeforeHooks(hookSupportData); @@ -72,7 +73,10 @@ void shouldPassDataAcrossStages(FlagValueType flagValueType) { var testHook = new TestHookWithData(); var hookSupportData = new HookSupportData(); hookSupport.setHooks(hookSupportData, List.of(testHook), flagValueType); - hookSupport.setHookContexts(hookSupportData, getBaseHookContextForType(flagValueType)); + hookSupport.setHookContexts( + hookSupportData, + getBaseHookContextForType(flagValueType), + new LayeredEvaluationContext(null, null, null, null)); hookSupport.executeBeforeHooks(hookSupportData); assertHookData(testHook, "before"); @@ -98,7 +102,10 @@ void shouldIsolateDataBetweenHooks(FlagValueType flagValueType) { var hookSupportData = new HookSupportData(); hookSupport.setHooks(hookSupportData, List.of(testHook1, testHook2), flagValueType); - hookSupport.setHookContexts(hookSupportData, getBaseHookContextForType(flagValueType)); + hookSupport.setHookContexts( + hookSupportData, + getBaseHookContextForType(flagValueType), + new LayeredEvaluationContext(null, null, null, null)); callAllHooks(hookSupportData); diff --git a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java index 437292fa8..1b9a9f535 100644 --- a/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java +++ b/src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java @@ -22,11 +22,8 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; -import org.junit.jupiter.api.Test; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.infra.Blackhole; /** @@ -99,7 +96,7 @@ public Optional before(HookContext ctx, Map transactionAttr2; public final EvaluationContext invocationContext; - public AllocationBenchmarkState(){ + public AllocationBenchmarkState() { long start = System.currentTimeMillis(); OpenFeatureAPI.getInstance().setProviderAndWait(new NoOpProvider()); OpenFeatureAPI.getInstance().setTransactionContextPropagator(new ThreadLocalTransactionContextPropagator()); @@ -45,6 +45,5 @@ public AllocationBenchmarkState(){ Map invocationAttrs = new HashMap<>(); invocationAttrs.put("invoke", new Value(3)); invocationContext = new ImmutableContext(invocationAttrs); - } } From a4f5dfea5cd1706cf9fe595ca4a5ca9941aee79b Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Tue, 18 Nov 2025 13:40:17 +0100 Subject: [PATCH 7/7] add tests Signed-off-by: christian.lutnik --- .../java/dev/openfeature/sdk/HookSupport.java | 6 +- .../sdk/LayeredEvaluationContext.java | 22 +- .../sdk/LayeredEvaluationContextTest.java | 231 ++++++++++++++++++ .../sdk/OpenFeatureClientTest.java | 51 ++++ 4 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 src/test/java/dev/openfeature/sdk/LayeredEvaluationContextTest.java diff --git a/src/main/java/dev/openfeature/sdk/HookSupport.java b/src/main/java/dev/openfeature/sdk/HookSupport.java index 31e30a04a..ab732d6b1 100644 --- a/src/main/java/dev/openfeature/sdk/HookSupport.java +++ b/src/main/java/dev/openfeature/sdk/HookSupport.java @@ -63,11 +63,9 @@ public void executeBeforeHooks(HookSupportData data) { .orElse(Optional.empty()); if (returnedEvalContext.isPresent()) { var returnedContext = returnedEvalContext.get(); - if (returnedContext.isEmpty()) { - continue; + if (!returnedContext.isEmpty()) { + data.evaluationContext.putHookContext(returnedContext.asMap()); } - - data.evaluationContext.putAllHookContexts(returnedContext.asMap()); } } } diff --git a/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java b/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java index e27392c0b..9bdc9d857 100644 --- a/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java +++ b/src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java @@ -17,8 +17,8 @@ public class LayeredEvaluationContext implements EvaluationContext { private final EvaluationContext clientContext; private final EvaluationContext invocationContext; private final HashMap hookContext = new HashMap<>(); - private final String targetingKey; + private String targetingKey; private Set keySet = null; /** @@ -54,7 +54,7 @@ public String getTargetingKey() { @Override public EvaluationContext merge(EvaluationContext overridingContext) { - return null; + throw new UnsupportedOperationException("LayeredEvaluationContext does not support merge operation"); } @Override @@ -68,7 +68,7 @@ public boolean isEmpty() { @Override public Set keySet() { - return new HashSet<>(ensureKeySet()); + return Collections.unmodifiableSet(ensureKeySet()); } private Set ensureKeySet() { @@ -174,11 +174,19 @@ public Map asObjectMap() { return map; } - public void putHookContext(String key, Value value) { - this.hookContext.put(key, value); - } + void putHookContext(Map context) { + if (context == null) { + return; + } - public void putAllHookContexts(Map context) { + var targetingKey = context.get("targetingKey"); + if (targetingKey != null) { + var targetingKeyStr = targetingKey.asString(); + if (targetingKeyStr != null) { + this.targetingKey = targetingKeyStr; + this.hookContext.put("targetingKey", targetingKey); + } + } this.hookContext.putAll(context); } } diff --git a/src/test/java/dev/openfeature/sdk/LayeredEvaluationContextTest.java b/src/test/java/dev/openfeature/sdk/LayeredEvaluationContextTest.java new file mode 100644 index 000000000..acd488e99 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/LayeredEvaluationContextTest.java @@ -0,0 +1,231 @@ +package dev.openfeature.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class LayeredEvaluationContextTest { + final EvaluationContext apiContext = + new MutableContext("api-level", Map.of("api", new Value("api"), "override", new Value("api"))); + final EvaluationContext transactionContext = new MutableContext( + "transaction-level", Map.of("transaction", new Value("transaction"), "override", new Value("transaction"))); + final EvaluationContext clientContext = + new MutableContext("client-level", Map.of("client", new Value("client"), "override", new Value("client"))); + final EvaluationContext invocationContext = new MutableContext( + "invocation-level", Map.of("invocation", new Value("invocation"), "override", new Value("invocation"))); + + @Test + void creatingLayeredContextWithNullsWorks() { + LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(null, null, null, null); + assertNotNull(layeredContext); + assertNull(layeredContext.getTargetingKey()); + assertEquals(Map.of(), layeredContext.asMap()); + assertEquals(Map.of(), layeredContext.asObjectMap()); + assertEquals(Map.of(), layeredContext.asUnmodifiableMap()); + assertEquals(Set.of(), layeredContext.keySet()); + assertTrue(layeredContext.isEmpty()); + } + + @Nested + class TargetingKey { + @Test + void hookWins() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + layeredContext.putHookContext(Map.of("targetingKey", new Value("hook-level"))); + assertEquals("hook-level", layeredContext.getTargetingKey()); + } + + @Test + void invocationWinsIfHookNotSet() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + assertEquals("invocation-level", layeredContext.getTargetingKey()); + } + + @Test + void clientWinsIfInvocationAndHookNotSet() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, null); + assertEquals("client-level", layeredContext.getTargetingKey()); + } + + @Test + void transactionWinsIfClientInvocationAndHookNotSet() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, null, null); + assertEquals("transaction-level", layeredContext.getTargetingKey()); + } + + @Test + void apiWinsIfTransactionClientInvocationAndHookNotSet() { + LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(apiContext, null, null, null); + assertEquals("api-level", layeredContext.getTargetingKey()); + } + } + + @Nested + class GetValue { + @Test + void doesNotOverrideUniqueValues() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + layeredContext.putHookContext(Map.of("hook", new Value("hook"), "targetingKey", new Value("hook-level"))); + + assertEquals("hook", layeredContext.getValue("hook").asString()); + assertEquals("invocation", layeredContext.getValue("invocation").asString()); + assertEquals("client", layeredContext.getValue("client").asString()); + assertEquals("transaction", layeredContext.getValue("transaction").asString()); + assertEquals("api", layeredContext.getValue("api").asString()); + } + + @Test + void hookWins() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + layeredContext.putHookContext( + Map.of("override", new Value("hook"), "targetingKey", new Value("hook-level"))); + assertEquals("hook", layeredContext.getValue("override").asString()); + } + + @Test + void invocationWinsIfHookNotSet() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + assertEquals("invocation", layeredContext.getValue("override").asString()); + } + + @Test + void clientWinsIfInvocationAndHookNotSet() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, null); + assertEquals("client", layeredContext.getValue("override").asString()); + } + + @Test + void transactionWinsIfClientInvocationAndHookNotSet() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, null, null); + assertEquals("transaction", layeredContext.getValue("override").asString()); + } + + @Test + void apiWinsIfTransactionClientInvocationAndHookNotSet() { + LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(apiContext, null, null, null); + assertEquals("api", layeredContext.getValue("override").asString()); + } + } + + @Nested + class KeySet { + @Test + void keySetIsGeneratedCorrectly() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + layeredContext.putHookContext(Map.of("hook", new Value("hook"), "targetingKey", new Value("hook-level"))); + + Set expectedKeys = Set.of( + "hook", + "invocation", + "client", + "transaction", + "api", + "override", + "targetingKey" // expected, even though not explicitly set + ); + + assertEquals(expectedKeys, layeredContext.keySet()); + } + } + + @Nested + class AsMap { + @Test + void mapIsGeneratedCorrectly() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + layeredContext.putHookContext(Map.of( + "hook", new Value("hook"), "override", new Value("hook"), "targetingKey", new Value("hook-level"))); + + var expectedKeys = Map.of( + "hook", new Value("hook"), + "invocation", new Value("invocation"), + "client", new Value("client"), + "transaction", new Value("transaction"), + "api", new Value("api"), + "override", new Value("hook"), + "targetingKey", new Value("hook-level") // expected, even though not explicitly set + ); + + assertEquals(expectedKeys, layeredContext.asMap()); + assertEquals(expectedKeys, layeredContext.asUnmodifiableMap()); + } + } + + @Nested + class AsObjectMap { + @Test + void mapIsGeneratedCorrectly() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext); + layeredContext.putHookContext(Map.of( + "hook", new Value("hook"), "override", new Value("hook"), "targetingKey", new Value("hook-level"))); + + var expectedKeys = Map.of( + "hook", "hook", + "invocation", "invocation", + "client", "client", + "transaction", "transaction", + "api", "api", + "override", "hook", + "targetingKey", "hook-level" // expected, even though not explicitly set in map + ); + + assertEquals(expectedKeys, layeredContext.asObjectMap()); + } + } + + @Nested + class IsEmpty { + @Test + void isEmptyWhenAllContextsAreNull() { + LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(null, null, null, null); + assertTrue(layeredContext.isEmpty()); + } + + @Test + void isNotEmptyWhenApiContextIsSet() { + LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(apiContext, null, null, null); + assertFalse(layeredContext.isEmpty()); + } + + @Test + void isNotEmptyWhenTransactionContextIsSet() { + LayeredEvaluationContext layeredContext = + new LayeredEvaluationContext(null, transactionContext, null, null); + assertFalse(layeredContext.isEmpty()); + } + + @Test + void isNotEmptyWhenClientContextIsSet() { + LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(null, null, clientContext, null); + assertFalse(layeredContext.isEmpty()); + } + + @Test + void isNotEmptyWhenInvocationContextIsSet() { + LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(null, null, null, invocationContext); + assertFalse(layeredContext.isEmpty()); + } + + @Test + void isNotEmptyWhenHookContextIsSet() { + LayeredEvaluationContext layeredContext = new LayeredEvaluationContext(null, null, null, null); + layeredContext.putHookContext(Map.of("hook", new Value("hook"), "targetingKey", new Value("hook-level"))); + assertFalse(layeredContext.isEmpty()); + } + } +} diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java index 91509bd45..31937ec2d 100644 --- a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java +++ b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java @@ -12,6 +12,8 @@ import dev.openfeature.sdk.fixtures.HookFixtures; import dev.openfeature.sdk.testutils.testProvider.TestProvider; import java.util.HashMap; +import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -135,4 +137,53 @@ void shouldSupportUsageOfHookData(boolean isError) { assertThat(testHook.hookData.get("error")).isEqualTo(null); } } + + @Test + void flagEvaluationsUseTheCorrectContext() { + OpenFeatureAPI api = new OpenFeatureAPI(); + api.setTransactionContextPropagator(new ThreadLocalTransactionContextPropagator()); + var provider = TestProvider.builder().allowUnknownFlags(true).initsToReady(); + api.setProviderAndWait(provider); + + var apiContext = new MutableContext("api-level", Map.of("api", new Value("api"), "override", new Value("api"))); + var transactionContext = new MutableContext( + "transaction-level", + Map.of("transaction", new Value("transaction"), "override", new Value("transaction"))); + var clientContext = new MutableContext( + "client-level", Map.of("client", new Value("client"), "override", new Value("client"))); + var invocationContext = new MutableContext( + "invocation-level", Map.of("invocation", new Value("invocation"), "override", new Value("invocation"))); + + var hookContext = + new MutableContext("hook-level", Map.of("hook", new Value("hook"), "override", new Value("hook"))); + var ctxHook = new Hook<>() { + @Override + public Optional before(HookContext ctx, Map hints) { + return Optional.of(hookContext); + } + }; + + api.setEvaluationContext(apiContext); + api.setTransactionContext(transactionContext); + + var client = api.getClient(); + client.addHooks(ctxHook); + client.setEvaluationContext(clientContext); + + client.getStringValue("flag", "idc", invocationContext); + + var flagEvaluations = provider.getFlagEvaluations(); + assertThat(flagEvaluations).hasSize(1); + + var evaluation = flagEvaluations.get(0); + assertThat(evaluation.evaluationContext.getValue("api").asString()).isEqualTo("api"); + assertThat(evaluation.evaluationContext.getValue("transaction").asString()) + .isEqualTo("transaction"); + assertThat(evaluation.evaluationContext.getValue("client").asString()).isEqualTo("client"); + assertThat(evaluation.evaluationContext.getValue("invocation").asString()) + .isEqualTo("invocation"); + assertThat(evaluation.evaluationContext.getValue("hook").asString()).isEqualTo("hook"); + assertThat(evaluation.evaluationContext.getValue("override").asString()).isEqualTo("hook"); + assertThat(evaluation.evaluationContext.getTargetingKey()).isEqualTo("hook-level"); + } }