Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 14 additions & 27 deletions src/main/java/dev/openfeature/sdk/HookSupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hook> hooks, FlagValueType type) {
List<Pair<Hook, HookContext>> hookContextPairs = new ArrayList<>();
Expand All @@ -35,35 +35,20 @@ public void setHooks(HookSupportData hookSupportData, List<Hook> 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<Hook, HookContext> 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<Hook, HookContext> hookContextPair : hookSupportData.hooks) {
var curHookContext = hookContextPair.getValue();
if (curHookContext != null) {
curHookContext.setCtx(evaluationContext);
}
}
}
}

public void executeBeforeHooks(HookSupportData data) {
// These traverse backwards from normal.
List<Pair<Hook, HookContext>> reversedHooks = new ArrayList<>(data.getHooks());
Expand All @@ -77,8 +62,10 @@ public void executeBeforeHooks(HookSupportData data) {
hook.before(hookContext, data.getHints()))
.orElse(Optional.empty());
if (returnedEvalContext.isPresent()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we are using Optionals already, we should use all the available syntax sugar, and switch to .ifPresent and chain as much as possible

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be nice, but Optionals in Java don't work well enough to get this with type safety. I would need some castings and redundant isPresent checks to get this to work

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but do we need the Optional wrapping on line 61? we want to reduce object churn etc. but we are generating a new Optional all the time - so i am wondering if we really need the Optional or if a null check would be sufficient here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to change as little as possible of unrelated code so that we can scientifically see the benefit of the new context. Other optimizations will come in a later PR

// update shared evaluation context for all hooks
updateEvaluationContext(data, data.getEvaluationContext().merge(returnedEvalContext.get()));
var returnedContext = returnedEvalContext.get();
if (!returnedContext.isEmpty()) {
data.evaluationContext.putHookContext(returnedContext.asMap());
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/dev/openfeature/sdk/HookSupportData.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class HookSupportData {

List<Pair<Hook, HookContext>> hooks;
EvaluationContext evaluationContext;
LayeredEvaluationContext evaluationContext;
Map<String, Object> hints;

HookSupportData() {}
Expand Down
205 changes: 205 additions & 0 deletions src/main/java/dev/openfeature/sdk/LayeredEvaluationContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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 {
Copy link
Contributor Author

@chrfwow chrfwow Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, I could also implement this as a list of EvaluationContexts, with each list entry representing a context level (index 0 = api, index 1 = transaction, ...) This could maybe make the code cleaner (especially the merging, as I would just need to add another list entry; I don't think merge will/should be called often on this context though), but also less performant. Wdyt?

Copy link
Member

@aepfli aepfli Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so theoretically before hooks can also add information for the evaluation context - and this should also be somehow merged into this. would it make sense to simple add all the evaluationContext returned to the layer, and iterate over them as a list?

Question if it is worth it - at least in theory it would be one less object

private final EvaluationContext apiContext;
private final EvaluationContext transactionContext;
private final EvaluationContext clientContext;
private final EvaluationContext invocationContext;
private final HashMap<String, Value> hookContext = new HashMap<>();

private String targetingKey;
private Set<String> 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;
}

/**
* Using this method should be avoided as it comes with a performance cost.
* Consider constructing a new LayeredEvaluationContext instead.
*
* <p>
* Does not modify this object.
*
* @param overridingContext overriding context
* @return A new LayeredEvaluationContext containing the context from this object, with the overridingContext
* merged on top.
* @deprecated Use of this method is discouraged due to performance considerations.
*/
@Deprecated
@Override
public EvaluationContext merge(EvaluationContext overridingContext) {
var merged = new LayeredEvaluationContext(apiContext, transactionContext, clientContext, invocationContext);
merged.hookContext.putAll(this.hookContext);
merged.hookContext.putAll(overridingContext.asMap());
var otherTargetingKey = overridingContext.getTargetingKey();
if (otherTargetingKey != null) {
merged.targetingKey = otherTargetingKey;
}
return merged;
}

@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<String> keySet() {
return Collections.unmodifiableSet(ensureKeySet());
}

private Set<String> 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make use of the Collections.emptySet() singleton while we have no keys?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I understand your comment, we only know that we have no keys at the end of this method

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was saying perhaps we could more lazily create a key set if there actually is any keys, and otherwise just return the empty set and avoid allocating something, but it's a micro-optimization.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But how do we know if we have any keys without generating the keyset? And after we generated it, we might as well return it, as we have allocated it anyway

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the allocation of the HashSet on 107 that I think we can save, possibly, which is a heap object... I think we can do all the null checks and then only create this object if any of them are non null, otherwise just return the singleton, right? Maybe I'm missing something.

Copy link
Contributor

@jarebudev jarebudev Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could the isEmpty() method be used to determine whether to create the HashSet, or if true, return the singleton?

return keys;
}

private Value getFromContext(EvaluationContext context, String key) {
if (context != null) {
return context.getValue(key);
}
return null;
}

@Override
public Value getValue(String key) {
var hookValue = hookContext.get(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<String, Value> asMap() {
var keySet = ensureKeySet();
var keys = keySet.size();
if (keys == 0) {
return new HashMap<>(1);
}
var map = new HashMap<String, Value>(keys);

for (String key : keySet) {
map.put(key, getValue(key));
}
return map;
}

@Override
public Map<String, Value> asUnmodifiableMap() {
var keySet = ensureKeySet();
var keys = keySet.size();
if (keys == 0) {
return Collections.emptyMap();
}
var map = new HashMap<String, Value>(keys);

for (String key : keySet) {
map.put(key, getValue(key));
}
return Collections.unmodifiableMap(map);
}

@Override
public Map<String, Object> asObjectMap() {
var keySet = ensureKeySet();
var keys = keySet.size();
if (keys == 0) {
return new HashMap<>(1);
}
var map = new HashMap<String, Object>(keys);

for (String key : keySet) {
map.put(key, convertValue(getValue(key)));
}
return map;
}

void putHookContext(Map<String, Value> context) {
if (context == null) {
return;
}

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);
}
}
11 changes: 7 additions & 4 deletions src/main/java/dev/openfeature/sdk/OpenFeatureClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ private <T> FlagEvaluationDetails<T> 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);
Expand All @@ -180,10 +186,7 @@ private <T> FlagEvaluationDetails<T> 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);

Expand Down
2 changes: 1 addition & 1 deletion src/test/java/dev/openfeature/sdk/HookSpecTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,7 @@ void mergeHappensCorrectly() {
invocationCtx,
FlagEvaluationOptions.builder().hook(hook).build());

ArgumentCaptor<ImmutableContext> captor = ArgumentCaptor.forClass(ImmutableContext.class);
ArgumentCaptor<LayeredEvaluationContext> captor = ArgumentCaptor.forClass(LayeredEvaluationContext.class);
verify(provider).getBooleanEvaluation(any(), any(), captor.capture());
EvaluationContext ec = captor.getValue();
assertEquals("works", ec.getValue("test").asString());
Expand Down
15 changes: 11 additions & 4 deletions src/test/java/dev/openfeature/sdk/HookSupportTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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");
Expand All @@ -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);

Expand Down
Loading
Loading