Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
63 changes: 39 additions & 24 deletions lib/src/main/java/growthbook/sdk/java/GrowthBook.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.Nullable;

import com.google.gson.JsonObject;
import growthbook.sdk.java.callback.ExperimentRunCallback;
import growthbook.sdk.java.evaluators.ConditionEvaluator;
Expand Down Expand Up @@ -50,21 +52,26 @@ public class GrowthBook implements IGrowthBook {
private final GrowthBookJsonUtils jsonUtils = GrowthBookJsonUtils.getInstance();

private List<ExperimentRunCallback> callbacks;
@Getter @Setter private JsonObject attributeOverrides;
@Getter
@Setter
private JsonObject attributeOverrides;

public EvaluationContext evaluationContext = null;
private final Map<String, AssignedExperiment> assigned;
private GBFeaturesRepository repository;

@Getter @Setter private Map<String, Object> forcedFeatureValues;
@Getter
@Setter
private Map<String, Object> forcedFeatureValues;

/**
* Initialize the GrowthBook SDK with a provided {@link GBContext}
*
* @param context {@link GBContext}
*/
public GrowthBook(GBContext context, GBFeaturesRepository repository) {
this.context = context;
this.repository=repository;
this.repository = repository;
this.assigned = new HashMap<>();
this.callbacks = new ArrayList<>();
this.featureEvaluator = new FeatureEvaluator();
Expand All @@ -81,7 +88,7 @@ public GrowthBook(GBContext context, GBFeaturesRepository repository) {
*/
public GrowthBook(GBFeaturesRepository repository) {
this.context = GBContext.builder().build();
this.repository= repository;
this.repository = repository;
// dependencies
this.assigned = new HashMap<>();
this.callbacks = new ArrayList<>();
Expand All @@ -105,7 +112,7 @@ public GrowthBook(GBFeaturesRepository repository) {
GrowthBook(GBContext context, FeatureEvaluator featureEvaluator, ConditionEvaluator conditionEvaluator, ExperimentEvaluator experimentEvaluator, GBFeaturesRepository repository) {
this.featureEvaluator = featureEvaluator;
this.conditionEvaluator = conditionEvaluator;
this.repository=repository;
this.repository = repository;
this.experimentEvaluatorEvaluator = experimentEvaluator;
this.context = context;
this.assigned = new HashMap<>();
Expand Down Expand Up @@ -150,7 +157,7 @@ private void initializeEvalContext() {
}

private EvaluationContext getEvaluationContext() {
Map<String, Feature<?>> res=this.repository.getParsedFeatures();
Map<String, Feature<?>> res = this.repository.getParsedFeatures();
this.context.setFeatures(res);
this.evaluationContext.setFeatures(res);
// Reset the stackContext for every evaluation.
Expand All @@ -165,12 +172,13 @@ private EvaluationContext getEvaluationContext() {
* There are a few ordered steps to evaluate a feature
* <p>
* 1. If the key doesn't exist in context.getFeatures()
* 1.1 Return getFeatureResult(null, "unknownFeature")
* 1.1 Return getFeatureResult(null, "unknownFeature")
* 2. Loop through the feature rules (if any)
* 2.1 If the rule has parentConditions (prerequisites) defined, loop through each one:
* 2.1.1 Call evalFeature on the parent condition
* 2.1.1.1 If a cycle is detected, break out of feature evaluation and return getFeatureResult(null, "cyclicPrerequisite")
* 2.1.2 Using the evaluated parent's result, create an object
* 2.1 If the rule has parentConditions (prerequisites) defined, loop through each one:
* 2.1.1 Call evalFeature on the parent condition
* 2.1.1.1 If a cycle is detected, break out of feature evaluation and return getFeatureResult(null, "cyclicPrerequisite")
* 2.1.2 Using the evaluated parent's result, create an object
*
* @param key name of the feature
* @param valueTypeClass the class of the generic, e.g. MyFeature.class
* @param <ValueType> Gson deserializable type
Expand All @@ -191,6 +199,7 @@ public <ValueType> FeatureResult<ValueType> evalFeature(String key, Class<ValueT
public void setAttributes(String attributesJsonString) {
this.context.setAttributesJson(attributesJsonString);
initializeEvalContext();
refreshStickyBucketService(null);
}

/**
Expand All @@ -202,28 +211,28 @@ public void setAttributes(String attributesJsonString) {
* 4. Return if forced via context
* 5. If experiment.active is set to false, return getExperimentResult(experiment)
* 6. Get the user hash value and return if empty
* 6.1 If sticky bucketing is permitted, check to see if a sticky bucket value exists. If so, skip steps 7-8.
* 6.1 If sticky bucketing is permitted, check to see if a sticky bucket value exists. If so, skip steps 7-8.
* 7. Apply filters and namespace
* 7.1 If experiment.filters is set
* 7.2 Else if experiment.namespace is set, return if not in range
* 7.1 If experiment.filters is set
* 7.2 Else if experiment.namespace is set, return if not in range
* 8. Return if any conditions are not met, return
* 8.1 If experiment.condition is set, return if it evaluates to false
* 8.2 If experiment.parentConditions is set (prerequisites), return if any of them evaluate to false. See the corresponding logic in evalFeature for more details. (Note that the gate flag should not be set in an experiment)
* 8.3 Apply any url targeting based on experiment.urlPatterns, return if no match
* 8.1 If experiment.condition is set, return if it evaluates to false
* 8.2 If experiment.parentConditions is set (prerequisites), return if any of them evaluate to false. See the corresponding logic in evalFeature for more details. (Note that the gate flag should not be set in an experiment)
* 8.3 Apply any url targeting based on experiment.urlPatterns, return if no match
* 9. Choose a variation
* 9.1 If a sticky bucket value exists, use it.
* 9.1.1 If the found sticky bucket version is blocked (doesn't exceed experiment.minBucketVersion), then skip enrollment
* 9.2 Else, calculate bucket ranges for the variations and choose one
* 9.1 If a sticky bucket value exists, use it.
* 9.1.1 If the found sticky bucket version is blocked (doesn't exceed experiment.minBucketVersion), then skip enrollment
* 9.2 Else, calculate bucket ranges for the variations and choose one
* 10. If assigned == -1, return getExperimentResult(experiment)
* 11. If experiment has a forced variation, return
* 12. If context.qaMode, return getExperimentResult(experiment)
* 13. Build the result object
* 14. Fire context.trackingCallback if set and the combination of hashAttribute, hashValue, experiment.key, and variationId has not been tracked before
* 15. Return result
*
* @param experiment Experiment object
* @return ExperimentResult instance
* @param experiment Experiment object
* @param <ValueType> Gson deserializable type
* @return ExperimentResult instance
*/
@Override
public <ValueType> ExperimentResult<ValueType> run(Experiment<ValueType> experiment) {
Expand All @@ -244,15 +253,17 @@ public <ValueType> ExperimentResult<ValueType> run(Experiment<ValueType> experim
public void setOwnStickyBucketService(@Nullable StickyBucketService stickyBucketService) {
this.context.setStickyBucketService(stickyBucketService);
initializeEvalContext();
refreshStickyBucketService(null);
}

/**
* Setting default in memory implementation of StickyBucketService interface
*/
@Override
public void setInMemoryStickyBucketService() {
this.context.setStickyBucketService(new InMemoryStickyBucketServiceImpl(new HashMap<>()));
this.context.setStickyBucketService(new InMemoryStickyBucketServiceImpl(new ConcurrentHashMap<>()));
initializeEvalContext();
refreshStickyBucketService(null);
}

/**
Expand Down Expand Up @@ -452,8 +463,9 @@ public <ValueType> ValueType getFeatureValue(String featureKey, ValueType defaul
* 4. If condition key is $not, check if !evalCondition(attributes, condition["$not"]) is false. If so, break out of the loop and return false
* 5. Otherwise, check if evalConditionValue(value, getPath(attributes, key)) is false. If so, break out of the loop and return false
* If none of the entries failed their checks, evalCondition returns true
*
* @param attributesJsonString A JsonObject of the user attributes to evaluate
* @param conditionJsonString A JsonObject of the condition
* @param conditionJsonString A JsonObject of the condition
* @return Whether the condition should be true for the user
*/
@Override
Expand Down Expand Up @@ -510,6 +522,7 @@ public void destroy() {

/**
* This method add new calback to list of ExperimentRunCallback
*
* @param callback ExperimentRunCallback interface
*/
@Override
Expand All @@ -521,6 +534,7 @@ public void subscribe(ExperimentRunCallback callback) {
* Update sticky bucketing configuration
* Method that get cached assignments
* and set it to Context's Sticky Bucket Assignments documents
*
* @param featuresDataModel Json in format of String. See info how it looks like here <a href="https://docs.growthbook.io/app/api#sdk-connection-endpoints">...</a>
*/
@Override
Expand All @@ -530,6 +544,7 @@ public void featuresAPIModelSuccessfully(String featuresDataModel) {

/**
* This method return boolean result if feature enabled by environment it would be present in context
*
* @param featureKey Feature name
* @return Whether feature is present in GBContext
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
import lombok.extern.slf4j.Slf4j;

import javax.annotation.Nullable;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Data
@Slf4j
Expand Down Expand Up @@ -188,7 +188,7 @@ public StickyBucketService getStickyBucketService() {
}

public void setInMemoryStickyBucketService() {
this.setStickyBucketService(new InMemoryStickyBucketServiceImpl(new HashMap<>()));
this.setStickyBucketService(new InMemoryStickyBucketServiceImpl(new ConcurrentHashMap<>()));
}

public void setGlobalAttributes(@Nullable String attributesJson) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package growthbook.sdk.java.stickyBucketing;

import com.google.gson.Gson;
import growthbook.sdk.java.model.StickyAssignmentsDocument;
import growthbook.sdk.java.sandbox.GbCacheManager;
import growthbook.sdk.java.util.GrowthBookJsonUtils;
import lombok.extern.slf4j.Slf4j;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/**
* File-based implementation of {@link StickyBucketService}.
*
* <p>
* This service persists sticky bucket assignments using a {@link GbCacheManager}.
* Each sticky assignment document is stored separately as a JSON string with a key
* composed of a prefix and the combination of attribute name and value.
* </p>
*
* <p>
* Example key format: <code>gbStickyBuckets__userId||12345</code>
* </p>
*/
@Slf4j
public class FileStickyBucketServiceImpl implements StickyBucketService {
private final String prefix;
private final GbCacheManager gbCacheManager;
private final Gson gson;
private final ConcurrentMap<String, Object> keyLocks;

/**
* Constructs a new service with the default prefix "gbStickyBuckets__".
*
* @param gbCacheManager the cache manager used to persist sticky assignments
*/
public FileStickyBucketServiceImpl(GbCacheManager gbCacheManager) {
this.gbCacheManager = gbCacheManager;
this.prefix = "gbStickyBuckets__";
this.gson = GrowthBookJsonUtils.getInstance().gson;
this.keyLocks = new ConcurrentHashMap<>();
}

/**
* Retrieves a sticky assignments document for the given attribute name and value.
*
* @param attributeName the name of the attribute
* @param attributeValue the value of the attribute
* @return the {@link StickyAssignmentsDocument},
* or {@code null} if not found
*/
@Override
public StickyAssignmentsDocument getAssignments(String attributeName, String attributeValue) {
String key = buildKey(attributeName, attributeValue);
Object lock = keyLocks.computeIfAbsent(key, k -> new Object());
synchronized (lock) {
try {
String json = gbCacheManager.loadCache(key);
if (json == null) {
return null;
}
return gson.fromJson(json, StickyAssignmentsDocument.class);
} catch (Exception e) {
log.error("Failed to load StickyAssignmentsDocument for {}={}, error: {}",
attributeName, attributeValue, e.getMessage(), e);
return null;
}
}
}

/**
* Saves a sticky assignments document in the cache.
* If a document with the same key exists, it will be overwritten.
*
* @param doc the {@link StickyAssignmentsDocument} to save
*/
@Override
public void saveAssignments(StickyAssignmentsDocument doc) {
String key = buildKey(doc.getAttributeName(), doc.getAttributeValue());
Object lock = keyLocks.computeIfAbsent(key, k -> new Object());
synchronized (lock) {
try {
String json = gson.toJson(doc);
gbCacheManager.saveContent(key, json);
} catch (Exception e) {
log.error("Failed to save StickyAssignmentsDocument for key={}, error: {}", key, e.getMessage(), e);
}
}
}


/**
* Retrieves all sticky assignments documents for the given attributes.
*
* @param attributes a map of attribute names to values
* @return a map where keys are
* "prefix + attributeName||attributeValue" and values are
* {@link StickyAssignmentsDocument} instances
*/
@Override
public Map<String, StickyAssignmentsDocument> getAllAssignments(Map<String, String> attributes) {
Map<String, StickyAssignmentsDocument> docs = new HashMap<>();
for (Map.Entry<String, String> entry : attributes.entrySet()) {
try {
String key = buildKey(entry.getKey(), entry.getValue());
String json = gbCacheManager.loadCache(key);
if (json == null) {
continue;
}
StickyAssignmentsDocument doc = gson.fromJson(json, StickyAssignmentsDocument.class);
if (doc != null) {
docs.put(entry.getKey() + "||" + entry.getValue(), doc);
}
} catch (Exception e) {
log.error("Error while loading sticky assignment for {}={}, error: {}",
entry.getKey(), entry.getValue(), e.getMessage(), e);
}
}
return docs;
}

/**
* Builds the cache key for a given attribute name and value, including the prefix.
*
* @param attributeName the attribute name
* @param attributeValue the attribute value
* @return the full cache key string
*/
private String buildKey(String attributeName, String attributeValue) {
return prefix + attributeName + "||" + attributeValue;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package growthbook.sdk.java.stickyBucketing;

import growthbook.sdk.java.model.StickyAssignmentsDocument;
import org.jetbrains.annotations.Nullable;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* For simple bucket persistence using the in memory's storage(Map) (can be polyfilled for other environments)
Expand All @@ -16,8 +18,8 @@ public class InMemoryStickyBucketServiceImpl implements StickyBucketService {
*
* @param localStorage a map to store sticky assignments documents in memory.
*/
public InMemoryStickyBucketServiceImpl(Map<String, StickyAssignmentsDocument> localStorage) {
this.localStorage = localStorage;
public InMemoryStickyBucketServiceImpl(@Nullable Map<String, StickyAssignmentsDocument> localStorage) {
this.localStorage = localStorage != null ? localStorage : new ConcurrentHashMap<>();
}

/**
Expand Down
Loading