Skip to content

Commit 35af591

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 0acbb00 commit 35af591

File tree

6 files changed

+665
-11
lines changed

6 files changed

+665
-11
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.
@@ -475,6 +476,42 @@ Variation getVariation(@Nonnull String experimentKey,
475476
return decisionService.getVariation(experiment,userId,filteredAttributes);
476477
}
477478

479+
/**
480+
* Force a user into a variation for a given experiment.
481+
* The forced variation value does not persist across application launches.
482+
* If the experiment key is not in the project file, this call fails and returns false.
483+
* If the variationKey is not in the experiment, this call fails.
484+
* @param experimentKey The key for the experiment.
485+
* @param userId The user ID to be used for bucketing.
486+
* @param variationKey The variation key to force the user into. If the variation key is null
487+
* then the forcedVariation for that experiment is removed.
488+
*
489+
* @return boolean A boolean value that indicates if the set completed successfully.
490+
*/
491+
public boolean setForcedVariation(@Nonnull String experimentKey,
492+
@Nonnull String userId,
493+
@Nullable String variationKey) {
494+
495+
496+
return projectConfig.setForcedVariation(experimentKey, userId, variationKey);
497+
}
498+
499+
/**
500+
* Gets the forced variation for a given user and experiment.
501+
* This method just calls into the {@link com.optimizely.ab.config.ProjectConfig#getForcedVariation(String, String)}
502+
* method of the same signature.
503+
*
504+
* @param experimentKey The key for the experiment.
505+
* @param userId The user ID to be used for bucketing.
506+
*
507+
* @return The variation the user was bucketed into. This value can be null if the
508+
* forced variation fails.
509+
*/
510+
public @Nullable Variation getForcedVariation(@Nonnull String experimentKey,
511+
@Nonnull String userId) {
512+
return projectConfig.getForcedVariation(experimentKey, userId);
513+
}
514+
478515
/**
479516
* @return the current {@link ProjectConfig} instance.
480517
*/

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
@@ -81,8 +81,14 @@ public DecisionService(@Nonnull Bucketer bucketer,
8181
return null;
8282
}
8383

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

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

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,17 @@
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;
26+
import javax.annotation.Nullable;
2327
import javax.annotation.concurrent.Immutable;
2428
import java.util.ArrayList;
2529
import java.util.Collections;
2630
import java.util.List;
2731
import java.util.Map;
32+
import java.util.concurrent.ConcurrentHashMap;
2833

2934
/**
3035
* Represents the Optimizely Project configuration.
@@ -52,6 +57,10 @@ public String toString() {
5257
}
5358
}
5459

60+
// logger
61+
private static final Logger logger = LoggerFactory.getLogger(ProjectConfig.class);
62+
63+
// ProjectConfig properties
5564
private final String accountId;
5665
private final String projectId;
5766
private final String revision;
@@ -75,6 +84,15 @@ public String toString() {
7584
private final Map<String, List<Experiment>> liveVariableIdToExperimentsMapping;
7685
private final Map<String, Map<String, LiveVariableUsageInstance>> variationToLiveVariableUsageInstanceMapping;
7786

87+
/**
88+
* Forced variations supersede any other mappings. They are transient and are not persistent or part of
89+
* the actual datafile. This contains all the forced variations
90+
* set by the user by calling {@link ProjectConfig#setForcedVariation(String, String, String)} (it is not the same as the
91+
* whitelisting forcedVariations data structure in the Experiments class).
92+
*/
93+
private transient ConcurrentHashMap<String, ConcurrentHashMap<String, String>> forcedVariationMapping = new ConcurrentHashMap<String, ConcurrentHashMap<String, String>>();
94+
95+
// v2 constructor
7896
public ProjectConfig(String accountId, String projectId, String version, String revision, List<Group> groups,
7997
List<Experiment> experiments, List<Attribute> attributes, List<EventType> eventType,
8098
List<Audience> audiences) {
@@ -236,6 +254,136 @@ public Map<String, Map<String, LiveVariableUsageInstance>> getVariationToLiveVar
236254
return variationToLiveVariableUsageInstanceMapping;
237255
}
238256

257+
public ConcurrentHashMap<String, ConcurrentHashMap<String, String>> getForcedVariationMapping() { return forcedVariationMapping; }
258+
259+
/**
260+
* Force a user into a variation for a given experiment.
261+
* The forced variation value does not persist across application launches.
262+
* If the experiment key is not in the project file, this call fails and returns false.
263+
*
264+
* @param experimentKey The key for the experiment.
265+
* @param userId The user ID to be used for bucketing.
266+
* @param variationKey The variation key to force the user into. If the variation key is null
267+
* then the forcedVariation for that experiment is removed.
268+
*
269+
* @return boolean A boolean value that indicates if the set completed successfully.
270+
*/
271+
public boolean setForcedVariation(@Nonnull String experimentKey,
272+
@Nonnull String userId,
273+
@Nullable String variationKey) {
274+
275+
// if the experiment is not a valid experiment key, don't set it.
276+
Experiment experiment = getExperimentKeyMapping().get(experimentKey);
277+
if (experiment == null){
278+
logger.error("Experiment {} does not exist in ProjectConfig for project {}", experimentKey, projectId);
279+
return false;
280+
}
281+
282+
Variation variation = null;
283+
284+
// keep in mind that you can pass in a variationKey that is null if you want to
285+
// remove the variation.
286+
if (variationKey != null) {
287+
variation = experiment.getVariationKeyToVariationMap().get(variationKey);
288+
// if the variation is not part of the experiment, return false.
289+
if (variation == null) {
290+
logger.error("Variation {} does not exist for experiment {}", variationKey, experimentKey);
291+
return false;
292+
}
293+
}
294+
295+
// if the user id is invalid, return false.
296+
if (userId == null || userId.trim().isEmpty()) {
297+
logger.error("User ID is invalid");
298+
return false;
299+
}
300+
301+
ConcurrentHashMap<String, String> experimentToVariation;
302+
if (!forcedVariationMapping.containsKey(userId)) {
303+
forcedVariationMapping.putIfAbsent(userId, new ConcurrentHashMap<String, String>());
304+
}
305+
experimentToVariation = forcedVariationMapping.get(userId);
306+
307+
boolean retVal = true;
308+
// if it is null remove the variation if it exists.
309+
if (variationKey == null) {
310+
String removedVariationId = experimentToVariation.remove(experiment.getId());
311+
if (removedVariationId != null) {
312+
Variation removedVariation = experiment.getVariationIdToVariationMap().get(removedVariationId);
313+
if (removedVariation != null) {
314+
logger.debug("Variation mapped to experiment \"%s\" has been removed for user \"%s\"", experiment.getKey(), userId);
315+
}
316+
else {
317+
logger.debug("Removed forced variation that did not exist in experiment");
318+
}
319+
}
320+
else {
321+
logger.debug("No variation for experiment {}", experimentKey);
322+
retVal = false;
323+
}
324+
}
325+
else {
326+
String previous = experimentToVariation.put(experiment.getId(), variation.getId());
327+
logger.debug("Set variation \"%s\" for experiment \"%s\" and user \"%s\" in the forced variation map.",
328+
variation.getKey(), experiment.getKey(), userId);
329+
if (previous != null) {
330+
Variation previousVariation = experiment.getVariationIdToVariationMap().get(previous);
331+
if (previousVariation != null) {
332+
logger.debug("forced variation {} replaced forced variation {} in forced variation map.",
333+
variation.getKey(), previousVariation.getKey());
334+
}
335+
}
336+
}
337+
338+
return retVal;
339+
}
340+
341+
/**
342+
* Gets the forced variation for a given user and experiment.
343+
*
344+
* @param experimentKey The key for the experiment.
345+
* @param userId The user ID to be used for bucketing.
346+
*
347+
* @return The variation the user was bucketed into. This value can be null if the
348+
* forced variation fails.
349+
*/
350+
public @Nullable Variation getForcedVariation(@Nonnull String experimentKey,
351+
@Nonnull String userId) {
352+
353+
// if the user id is invalid, return false.
354+
if (userId == null || userId.trim().isEmpty()) {
355+
logger.error("User ID is invalid");
356+
return null;
357+
}
358+
359+
if (experimentKey == null || experimentKey.isEmpty()) {
360+
logger.error("experiment key is invalid");
361+
return null;
362+
}
363+
364+
Map<String, String> experimentToVariation = getForcedVariationMapping().get(userId);
365+
if (experimentToVariation != null) {
366+
Experiment experiment = getExperimentKeyMapping().get(experimentKey);
367+
if (experiment == null) {
368+
logger.debug("No experiment \"%s\" mapped to user \"%s\" in the forced variation map ", experimentKey, userId);
369+
return null;
370+
}
371+
String variationId = experimentToVariation.get(experiment.getId());
372+
if (variationId != null) {
373+
Variation variation = experiment.getVariationIdToVariationMap().get(variationId);
374+
if (variation != null) {
375+
logger.debug("Variation \"%s\" is mapped to experiment \"%s\" and user \"%s\" in the forced variation map",
376+
variation.getKey(), experimentKey, userId);
377+
return variation;
378+
}
379+
}
380+
else {
381+
logger.debug("No variation for experiment \"%s\" mapped to user \"%s\" in the forced variation map ", experimentKey, userId);
382+
}
383+
}
384+
return null;
385+
}
386+
239387
@Override
240388
public String toString() {
241389
return "ProjectConfig{" +
@@ -259,6 +407,7 @@ public String toString() {
259407
", groupIdMapping=" + groupIdMapping +
260408
", liveVariableIdToExperimentsMapping=" + liveVariableIdToExperimentsMapping +
261409
", variationToLiveVariableUsageInstanceMapping=" + variationToLiveVariableUsageInstanceMapping +
410+
", forcedVariationMapping=" + forcedVariationMapping +
262411
'}';
263412
}
264413
}

0 commit comments

Comments
 (0)