Skip to content

Commit 38b2fb8

Browse files
Feature/force bucketing (#136)
* added force bucketing * get test working * unit test forced variations * fix EventBuilderV2Test using AssertNotEqual * methods and map in project config. decision logic in decision service * use variation and experiment ids instead of key for storage. use putIfAbsent * added logging for projectConfig
1 parent 4a2f0fa commit 38b2fb8

File tree

7 files changed

+741
-34
lines changed

7 files changed

+741
-34
lines changed

core-api/src/main/java/com/optimizely/ab/Optimizely.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
import java.util.HashMap;
5454
import java.util.List;
5555
import java.util.Map;
56+
import java.util.concurrent.ConcurrentHashMap;
5657

5758
/**
5859
* Top-level container class for Optimizely functionality.
@@ -615,6 +616,42 @@ Variation getVariation(@Nonnull String experimentKey,
615616
return decisionService.getVariation(experiment,userId,filteredAttributes);
616617
}
617618

619+
/**
620+
* Force a user into a variation for a given experiment.
621+
* The forced variation value does not persist across application launches.
622+
* If the experiment key is not in the project file, this call fails and returns false.
623+
* If the variationKey is not in the experiment, this call fails.
624+
* @param experimentKey The key for the experiment.
625+
* @param userId The user ID to be used for bucketing.
626+
* @param variationKey The variation key to force the user into. If the variation key is null
627+
* then the forcedVariation for that experiment is removed.
628+
*
629+
* @return boolean A boolean value that indicates if the set completed successfully.
630+
*/
631+
public boolean setForcedVariation(@Nonnull String experimentKey,
632+
@Nonnull String userId,
633+
@Nullable String variationKey) {
634+
635+
636+
return projectConfig.setForcedVariation(experimentKey, userId, variationKey);
637+
}
638+
639+
/**
640+
* Gets the forced variation for a given user and experiment.
641+
* This method just calls into the {@link com.optimizely.ab.config.ProjectConfig#getForcedVariation(String, String)}
642+
* method of the same signature.
643+
*
644+
* @param experimentKey The key for the experiment.
645+
* @param userId The user ID to be used for bucketing.
646+
*
647+
* @return The variation the user was bucketed into. This value can be null if the
648+
* forced variation fails.
649+
*/
650+
public @Nullable Variation getForcedVariation(@Nonnull String experimentKey,
651+
@Nonnull String userId) {
652+
return projectConfig.getForcedVariation(experimentKey, userId);
653+
}
654+
618655
/**
619656
* @return the current {@link ProjectConfig} instance.
620657
*/

core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,14 @@ public DecisionService(@Nonnull Bucketer bucketer,
8282
return null;
8383
}
8484

85+
// look for forced bucketing first.
86+
Variation variation = projectConfig.getForcedVariation(experiment.getKey(), userId);
87+
8588
// check for whitelisting
86-
Variation variation = getWhitelistedVariation(experiment, userId);
89+
if (variation == null) {
90+
variation = getWhitelistedVariation(experiment, userId);
91+
}
92+
8793
if (variation != null) {
8894
return variation;
8995
}

core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@
1919
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
2020
import com.optimizely.ab.config.audience.Audience;
2121
import com.optimizely.ab.config.audience.Condition;
22+
import org.slf4j.Logger;
23+
import org.slf4j.LoggerFactory;
2224

25+
import javax.annotation.Nonnull;
2326
import javax.annotation.Nullable;
2427
import javax.annotation.concurrent.Immutable;
2528
import java.util.ArrayList;
2629
import java.util.Collections;
2730
import java.util.HashMap;
2831
import java.util.List;
2932
import java.util.Map;
33+
import java.util.concurrent.ConcurrentHashMap;
3034

3135
/**
3236
* Represents the Optimizely Project configuration.
@@ -54,6 +58,10 @@ public String toString() {
5458
}
5559
}
5660

61+
// logger
62+
private static final Logger logger = LoggerFactory.getLogger(ProjectConfig.class);
63+
64+
// ProjectConfig properties
5765
private final String accountId;
5866
private final String projectId;
5967
private final String revision;
@@ -85,6 +93,14 @@ public String toString() {
8593
private final Map<String, Map<String, LiveVariableUsageInstance>> variationToLiveVariableUsageInstanceMapping;
8694
private final Map<String, Experiment> variationIdToExperimentMapping;
8795

96+
/**
97+
* Forced variations supersede any other mappings. They are transient and are not persistent or part of
98+
* the actual datafile. This contains all the forced variations
99+
* set by the user by calling {@link ProjectConfig#setForcedVariation(String, String, String)} (it is not the same as the
100+
* whitelisting forcedVariations data structure in the Experiments class).
101+
*/
102+
private transient ConcurrentHashMap<String, ConcurrentHashMap<String, String>> forcedVariationMapping = new ConcurrentHashMap<String, ConcurrentHashMap<String, String>>();
103+
88104
// v2 constructor
89105
public ProjectConfig(String accountId, String projectId, String version, String revision, List<Group> groups,
90106
List<Experiment> experiments, List<Attribute> attributes, List<EventType> eventType,
@@ -318,6 +334,136 @@ public Map<String, FeatureFlag> getFeatureKeyMapping() {
318334
return featureKeyMapping;
319335
}
320336

337+
public ConcurrentHashMap<String, ConcurrentHashMap<String, String>> getForcedVariationMapping() { return forcedVariationMapping; }
338+
339+
/**
340+
* Force a user into a variation for a given experiment.
341+
* The forced variation value does not persist across application launches.
342+
* If the experiment key is not in the project file, this call fails and returns false.
343+
*
344+
* @param experimentKey The key for the experiment.
345+
* @param userId The user ID to be used for bucketing.
346+
* @param variationKey The variation key to force the user into. If the variation key is null
347+
* then the forcedVariation for that experiment is removed.
348+
*
349+
* @return boolean A boolean value that indicates if the set completed successfully.
350+
*/
351+
public boolean setForcedVariation(@Nonnull String experimentKey,
352+
@Nonnull String userId,
353+
@Nullable String variationKey) {
354+
355+
// if the experiment is not a valid experiment key, don't set it.
356+
Experiment experiment = getExperimentKeyMapping().get(experimentKey);
357+
if (experiment == null){
358+
logger.error("Experiment {} does not exist in ProjectConfig for project {}", experimentKey, projectId);
359+
return false;
360+
}
361+
362+
Variation variation = null;
363+
364+
// keep in mind that you can pass in a variationKey that is null if you want to
365+
// remove the variation.
366+
if (variationKey != null) {
367+
variation = experiment.getVariationKeyToVariationMap().get(variationKey);
368+
// if the variation is not part of the experiment, return false.
369+
if (variation == null) {
370+
logger.error("Variation {} does not exist for experiment {}", variationKey, experimentKey);
371+
return false;
372+
}
373+
}
374+
375+
// if the user id is invalid, return false.
376+
if (userId == null || userId.trim().isEmpty()) {
377+
logger.error("User ID is invalid");
378+
return false;
379+
}
380+
381+
ConcurrentHashMap<String, String> experimentToVariation;
382+
if (!forcedVariationMapping.containsKey(userId)) {
383+
forcedVariationMapping.putIfAbsent(userId, new ConcurrentHashMap<String, String>());
384+
}
385+
experimentToVariation = forcedVariationMapping.get(userId);
386+
387+
boolean retVal = true;
388+
// if it is null remove the variation if it exists.
389+
if (variationKey == null) {
390+
String removedVariationId = experimentToVariation.remove(experiment.getId());
391+
if (removedVariationId != null) {
392+
Variation removedVariation = experiment.getVariationIdToVariationMap().get(removedVariationId);
393+
if (removedVariation != null) {
394+
logger.debug("Variation mapped to experiment \"%s\" has been removed for user \"%s\"", experiment.getKey(), userId);
395+
}
396+
else {
397+
logger.debug("Removed forced variation that did not exist in experiment");
398+
}
399+
}
400+
else {
401+
logger.debug("No variation for experiment {}", experimentKey);
402+
retVal = false;
403+
}
404+
}
405+
else {
406+
String previous = experimentToVariation.put(experiment.getId(), variation.getId());
407+
logger.debug("Set variation \"%s\" for experiment \"%s\" and user \"%s\" in the forced variation map.",
408+
variation.getKey(), experiment.getKey(), userId);
409+
if (previous != null) {
410+
Variation previousVariation = experiment.getVariationIdToVariationMap().get(previous);
411+
if (previousVariation != null) {
412+
logger.debug("forced variation {} replaced forced variation {} in forced variation map.",
413+
variation.getKey(), previousVariation.getKey());
414+
}
415+
}
416+
}
417+
418+
return retVal;
419+
}
420+
421+
/**
422+
* Gets the forced variation for a given user and experiment.
423+
*
424+
* @param experimentKey The key for the experiment.
425+
* @param userId The user ID to be used for bucketing.
426+
*
427+
* @return The variation the user was bucketed into. This value can be null if the
428+
* forced variation fails.
429+
*/
430+
public @Nullable Variation getForcedVariation(@Nonnull String experimentKey,
431+
@Nonnull String userId) {
432+
433+
// if the user id is invalid, return false.
434+
if (userId == null || userId.trim().isEmpty()) {
435+
logger.error("User ID is invalid");
436+
return null;
437+
}
438+
439+
if (experimentKey == null || experimentKey.isEmpty()) {
440+
logger.error("experiment key is invalid");
441+
return null;
442+
}
443+
444+
Map<String, String> experimentToVariation = getForcedVariationMapping().get(userId);
445+
if (experimentToVariation != null) {
446+
Experiment experiment = getExperimentKeyMapping().get(experimentKey);
447+
if (experiment == null) {
448+
logger.debug("No experiment \"%s\" mapped to user \"%s\" in the forced variation map ", experimentKey, userId);
449+
return null;
450+
}
451+
String variationId = experimentToVariation.get(experiment.getId());
452+
if (variationId != null) {
453+
Variation variation = experiment.getVariationIdToVariationMap().get(variationId);
454+
if (variation != null) {
455+
logger.debug("Variation \"%s\" is mapped to experiment \"%s\" and user \"%s\" in the forced variation map",
456+
variation.getKey(), experimentKey, userId);
457+
return variation;
458+
}
459+
}
460+
else {
461+
logger.debug("No variation for experiment \"%s\" mapped to user \"%s\" in the forced variation map ", experimentKey, userId);
462+
}
463+
}
464+
return null;
465+
}
466+
321467
@Override
322468
public String toString() {
323469
return "ProjectConfig{" +
@@ -344,6 +490,7 @@ public String toString() {
344490
", groupIdMapping=" + groupIdMapping +
345491
", liveVariableIdToExperimentsMapping=" + liveVariableIdToExperimentsMapping +
346492
", variationToLiveVariableUsageInstanceMapping=" + variationToLiveVariableUsageInstanceMapping +
493+
", forcedVariationMapping=" + forcedVariationMapping +
347494
", variationIdToExperimentMapping=" + variationIdToExperimentMapping +
348495
'}';
349496
}

0 commit comments

Comments
 (0)