Skip to content

Conversation

@chrfwow
Copy link
Contributor

@chrfwow chrfwow commented Nov 12, 2025

Introduces layered context to reduce memory churn

Part of #1718

Signed-off-by: christian.lutnik <[email protected]>
Signed-off-by: christian.lutnik <[email protected]>
# Conflicts:
#	src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmark.java
Signed-off-by: christian.lutnik <[email protected]>
Signed-off-by: christian.lutnik <[email protected]>
Signed-off-by: christian.lutnik <[email protected]>
Signed-off-by: christian.lutnik <[email protected]>
@chrfwow chrfwow changed the title Layered context feat: Layered context Nov 12, 2025
@codecov
Copy link

codecov bot commented Nov 12, 2025

Codecov Report

❌ Patch coverage is 94.93671% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.72%. Comparing base (060dd41) to head (499436b).
⚠️ Report is 68 commits behind head on main.

Files with missing lines Patch % Lines
.../dev/openfeature/sdk/LayeredEvaluationContext.java 94.61% 1 Missing and 6 partials ⚠️
src/main/java/dev/openfeature/sdk/HookSupport.java 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #1717      +/-   ##
============================================
+ Coverage     91.69%   93.72%   +2.03%     
- Complexity      517      597      +80     
============================================
  Files            51       53       +2     
  Lines          1264     1403     +139     
  Branches        112      150      +38     
============================================
+ Hits           1159     1315     +156     
+ Misses           68       50      -18     
- Partials         37       38       +1     
Flag Coverage Δ
unittests 93.72% <94.93%> (+2.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

* 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

Signed-off-by: christian.lutnik <[email protected]>
@chrfwow chrfwow marked this pull request as ready for review November 19, 2025 08:34
@chrfwow chrfwow requested review from a team as code owners November 19, 2025 08:34
@chrfwow
Copy link
Contributor Author

chrfwow commented Nov 19, 2025

Benchmark results:
(I ran AllocationBenchmark#main with the IntelliJ Profiler. This is not a proper JMH benchmark, so the values are not representative per evaluation, but should be helpful to compare both versions)

Baseline:
Duration: 5267 ms
Allocations: 16.27 GB
Flamegraph of allocations:
image

This PR:
Duration: 1649 ms
Allocations: 7.75 GB
Flamegraph of allocations:
image

Copy link
Member

@aepfli aepfli left a comment

Choose a reason for hiding this comment

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

looks good to me, i only have a nit (regarding optionals) and a question for beforehooks :)

Optional<EvaluationContext> returnedEvalContext = Optional.ofNullable(
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

* 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
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

Signed-off-by: christian.lutnik <[email protected]>
Signed-off-by: christian.lutnik <[email protected]>
Signed-off-by: christian.lutnik <[email protected]>
# Conflicts:
#	src/test/java/dev/openfeature/sdk/benchmark/AllocationBenchmarkState.java
@chrfwow chrfwow requested a review from aepfli November 19, 2025 10:47
Signed-off-by: christian.lutnik <[email protected]>
@chrfwow chrfwow requested a review from a team November 19, 2025 10:48
Signed-off-by: christian.lutnik <[email protected]>
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?

@toddbaert
Copy link
Member

/gemini review

@toddbaert toddbaert self-requested a review November 20, 2025 00:59
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a LayeredEvaluationContext to reduce memory churn during flag evaluations by avoiding the merging of evaluation contexts into a single map. The implementation looks solid and achieves its goal. I've added one suggestion to improve the performance of asMap(), asUnmodifiableMap(), and asObjectMap() methods in the new LayeredEvaluationContext class by optimizing how the final map is constructed. Overall, this is a great improvement for performance.

Comment on lines 176 to 218
public Map<String, Value> asMap() {
var keySet = ensureKeySet();
var keys = keySet.size();
if (keys == 0) {
return new HashMap<>(0);
}
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<>(0);
}
var map = new HashMap<String, Object>(keys);

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

Choose a reason for hiding this comment

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

medium

The implementations of asMap(), asUnmodifiableMap(), and asObjectMap() can be made more efficient. The current approach first builds a complete keySet by iterating through all context layers, and then for each key, it calls getValue(), which again iterates through the layers to find the value. This results in multiple traversals of the context layers for each call to these methods.

A more performant approach would be to iterate through the context layers from lowest to highest precedence, adding all entries from each context to a result map. This way, values from higher-precedence contexts will naturally overwrite those from lower-precedence ones, and each context is traversed only once. This also aligns better with the goal of reducing memory churn by avoiding the creation of an intermediate keySet.

@Override
public Map<String, Value> asMap() {
    var map = new HashMap<String, Value>();
    // Apply from lowest to highest precedence, so that higher precedence contexts overwrite lower ones.
    if (apiContext != null) {
        map.putAll(apiContext.asMap());
    }
    if (transactionContext != null) {
        map.putAll(transactionContext.asMap());
    }
    if (clientContext != null) {
        map.putAll(clientContext.asMap());
    }
    if (invocationContext != null) {
        map.putAll(invocationContext.asMap());
    }
    if (hookContexts != null) {
        for (EvaluationContext hookContext : hookContexts) {
            if (hookContext != null) {
                map.putAll(hookContext.asMap());
            }
        }
    }
    return map;
}

@Override
public Map<String, Value> asUnmodifiableMap() {
    return Collections.unmodifiableMap(asMap());
}

@Override
public Map<String, Object> asObjectMap() {
    var map = new HashMap<String, Object>();
    // Apply from lowest to highest precedence, so that higher precedence contexts overwrite lower ones.
    if (apiContext != null) {
        map.putAll(apiContext.asObjectMap());
    }
    if (transactionContext != null) {
        map.putAll(transactionContext.asObjectMap());
    }
    if (clientContext != null) {
        map.putAll(clientContext.asObjectMap());
    }
    if (invocationContext != null) {
        map.putAll(invocationContext.asObjectMap());
    }
    if (hookContexts != null) {
        for (EvaluationContext hookContext : hookContexts) {
            if (hookContext != null) {
                map.putAll(hookContext.asObjectMap());
            }
        }
    }
    return map;
}

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 really helped a lot

Copy link
Member

@toddbaert toddbaert left a comment

Choose a reason for hiding this comment

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

@chrfwow looks excellent.

I've added some possible small optimizations, but no blockers.

Signed-off-by: christian.lutnik <[email protected]>
Signed-off-by: christian.lutnik <[email protected]>
Signed-off-by: christian.lutnik <[email protected]>
Signed-off-by: christian.lutnik <[email protected]>

@Override
public boolean isEmpty() {
return (hookContexts == null || hookContexts.isEmpty())
Copy link
Contributor

Choose a reason for hiding this comment

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

If the hookContexts collection contains at least one EvaluationContext, then a call to isEmpty will return false.

But we do not check inside the hookContexts collection that all the EvaluationContexts that it contains are all empty.

should these also be checked for emptiness?

e.g. (hookContexts == null || hookContexts.isEmpty() || hookContexts.stream().allMatch(ec -> ec.isEmpty()))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice catch!

@chrfwow chrfwow requested a review from jarebudev November 25, 2025 15:03
Signed-off-by: christian.lutnik <[email protected]>
@toddbaert toddbaert changed the title feat: Layered context feat: Layered context to reduce memory churn Nov 25, 2025
@toddbaert
Copy link
Member

I'll merge this tomorrow unless I hear objections.

Signed-off-by: christian.lutnik <[email protected]>
@sonarqubecloud
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants