Skip to content

Commit b378924

Browse files
authored
rollout bucketing (#140)
* rollout bucketing using fallback strategy
1 parent 9085e0a commit b378924

File tree

7 files changed

+996
-89
lines changed

7 files changed

+996
-89
lines changed

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

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

5857
/**
5958
* Top-level container class for Optimizely functionality.
@@ -562,7 +561,8 @@ else if (!variable.getType().equals(variableType)) {
562561
else {
563562
logger.info("User \"" + userId +
564563
"\" was not bucketed into any variation for feature flag \"" + featureKey +
565-
"\". The default value is being returned."
564+
"\". The default value \"" + variableValue +
565+
"\" for \"" + variableKey + "\" is being returned."
566566
);
567567
}
568568

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

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
import com.optimizely.ab.config.Experiment;
2121
import com.optimizely.ab.config.FeatureFlag;
2222
import com.optimizely.ab.config.ProjectConfig;
23+
import com.optimizely.ab.config.Rollout;
2324
import com.optimizely.ab.config.Variation;
25+
import com.optimizely.ab.config.audience.Audience;
2426
import com.optimizely.ab.error.ErrorHandler;
2527
import com.optimizely.ab.internal.ExperimentUtils;
2628
import org.slf4j.Logger;
@@ -165,7 +167,80 @@ public DecisionService(@Nonnull Bucketer bucketer,
165167
logger.info("The feature flag \"" + featureFlag.getKey() + "\" is not used in any experiments.");
166168
}
167169

168-
return null;
170+
Variation variation = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes);
171+
if (variation == null) {
172+
logger.info("The user \"" + userId + "\" was not bucketed into a rollout for feature flag \"" +
173+
featureFlag.getKey() + "\".");
174+
}
175+
else {
176+
logger.info("The user \"" + userId + "\" was bucketed into a rollout for feature flag \"" +
177+
featureFlag.getKey() + "\".");
178+
}
179+
return variation;
180+
}
181+
182+
/**
183+
* Try to bucket the user into a rollout rule.
184+
* Evaluate the user for rules in priority order by seeing if the user satisfies the audience.
185+
* Fall back onto the everyone else rule if the user is ever excluded from a rule due to traffic allocation.
186+
* @param featureFlag The feature flag the user wants to access.
187+
* @param userId User Identifier
188+
* @param filteredAttributes A map of filtered attributes.
189+
* @return null if the user is not bucketed into the rollout or if the feature flag was not attached to a rollout.
190+
* {@link Variation} the user is bucketed into fi the user is successfully bucketed.
191+
*/
192+
@Nullable Variation getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag,
193+
@Nonnull String userId,
194+
@Nonnull Map<String, String> filteredAttributes) {
195+
// use rollout to get variation for feature
196+
if (featureFlag.getRolloutId().isEmpty()) {
197+
logger.info("The feature flag \"" + featureFlag.getKey() + "\" is not used in a rollout.");
198+
return null;
199+
}
200+
Rollout rollout = projectConfig.getRolloutIdMapping().get(featureFlag.getRolloutId());
201+
if (rollout == null) {
202+
logger.error("The rollout with id \"" + featureFlag.getRolloutId() +
203+
"\" was not found in the datafile for feature flag \"" + featureFlag.getKey() +
204+
"\".");
205+
return null;
206+
}
207+
int rolloutRulesLength = rollout.getExperiments().size();
208+
Variation variation;
209+
// for all rules before the everyone else rule
210+
for (int i = 0; i < rolloutRulesLength - 1; i++) {
211+
Experiment rolloutRule= rollout.getExperiments().get(i);
212+
Audience audience = projectConfig.getAudienceIdMapping().get(rolloutRule.getAudienceIds().get(0));
213+
if (!rolloutRule.isActive()) {
214+
logger.debug("Did not attempt to bucket user into rollout rule for audience \"" +
215+
audience.getName() + "\" since the rule is not active.");
216+
}
217+
else if (ExperimentUtils.isUserInExperiment(projectConfig, rolloutRule, filteredAttributes)) {
218+
logger.debug("Attempting to bucket user \"" + userId +
219+
"\" into rollout rule for audience \"" + audience.getName() +
220+
"\".");
221+
variation = bucketer.bucket(rolloutRule, userId);
222+
if (variation == null) {
223+
logger.debug("User \"" + userId +
224+
"\" was excluded due to traffic allocation.");
225+
break;
226+
}
227+
return variation;
228+
}
229+
else {
230+
logger.debug("User \"" + userId +
231+
"\" did not meet the conditions to be in rollout rule for audience \"" + audience.getName() +
232+
"\".");
233+
}
234+
}
235+
// get last rule which is the everyone else rule
236+
Experiment everyoneElseRule = rollout.getExperiments().get(rolloutRulesLength - 1);
237+
variation = bucketer.bucket(everyoneElseRule, userId); // ignore audience
238+
if (variation == null) {
239+
logger.debug("User \"" + userId +
240+
"\" was excluded from the \"Everyone Else\" rule for feature flag \"" + featureFlag.getKey() +
241+
"\".");
242+
}
243+
return variation;
169244
}
170245

171246
/**

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ public String toString() {
8787
private final Map<String, Audience> audienceIdMapping;
8888
private final Map<String, Experiment> experimentIdMapping;
8989
private final Map<String, Group> groupIdMapping;
90+
private final Map<String, Rollout> rolloutIdMapping;
9091

9192
// other mappings
9293
private final Map<String, List<Experiment>> liveVariableIdToExperimentsMapping;
@@ -192,6 +193,7 @@ public ProjectConfig(String accountId,
192193
this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(audiences);
193194
this.experimentIdMapping = ProjectConfigUtils.generateIdMapping(this.experiments);
194195
this.groupIdMapping = ProjectConfigUtils.generateIdMapping(groups);
196+
this.rolloutIdMapping = ProjectConfigUtils.generateIdMapping(this.rollouts);
195197

196198
if (liveVariables == null) {
197199
this.liveVariables = null;
@@ -318,6 +320,10 @@ public Map<String, Group> getGroupIdMapping() {
318320
return groupIdMapping;
319321
}
320322

323+
public Map<String, Rollout> getRolloutIdMapping() {
324+
return rolloutIdMapping;
325+
}
326+
321327
public Map<String, LiveVariable> getLiveVariableKeyMapping() {
322328
return liveVariableKeyMapping;
323329
}
@@ -488,6 +494,7 @@ public String toString() {
488494
", audienceIdMapping=" + audienceIdMapping +
489495
", experimentIdMapping=" + experimentIdMapping +
490496
", groupIdMapping=" + groupIdMapping +
497+
", rolloutIdMapping=" + rolloutIdMapping +
491498
", liveVariableIdToExperimentsMapping=" + liveVariableIdToExperimentsMapping +
492499
", variationToLiveVariableUsageInstanceMapping=" + variationToLiveVariableUsageInstanceMapping +
493500
", forcedVariationMapping=" + forcedVariationMapping +

core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
import com.optimizely.ab.event.LogEvent;
3838
import com.optimizely.ab.event.internal.EventBuilder;
3939
import com.optimizely.ab.event.internal.EventBuilderV2;
40-
import com.optimizely.ab.event.internal.payload.Feature;
4140
import com.optimizely.ab.internal.LogbackVerifier;
4241
import com.optimizely.ab.notification.NotificationListener;
4342
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -53,13 +52,13 @@
5352
import org.mockito.junit.MockitoRule;
5453

5554
import java.io.IOException;
55+
import java.util.ArrayList;
5656
import java.util.Arrays;
5757
import java.util.Collection;
58-
import java.util.Map;
58+
import java.util.Collections;
5959
import java.util.HashMap;
6060
import java.util.List;
61-
import java.util.Collections;
62-
import java.util.ArrayList;
61+
import java.util.Map;
6362

6463
import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigJsonV2;
6564
import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigJsonV3;
@@ -80,14 +79,17 @@
8079
import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY;
8180
import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_PAUSED_EXPERIMENT_KEY;
8281
import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_MULTI_VARIATE_FEATURE;
82+
import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE;
8383
import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_MULTI_VARIATE_FEATURE_KEY;
84-
import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_STRING_KEY;
84+
import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY;
85+
import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_SINGLE_VARIABLE_DOUBLE_KEY;
8586
import static com.optimizely.ab.config.ValidProjectConfigV4.MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED;
8687
import static com.optimizely.ab.config.ValidProjectConfigV4.PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL;
87-
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_FIRST_LETTER_DEFAULT_VALUE;
88+
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE;
89+
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_BOOLEAN_VARIABLE_KEY;
90+
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_DOUBLE_DEFAULT_VALUE;
91+
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_DOUBLE_VARIABLE_KEY;
8892
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_FIRST_LETTER_KEY;
89-
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_STRING_VARIABLE_DEFAULT_VALUE;
90-
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIABLE_STRING_VARIABLE_KEY;
9193
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIATION_MULTIVARIATE_EXPERIMENT_GRED;
9294
import static com.optimizely.ab.config.ValidProjectConfigV4.VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY;
9395
import static com.optimizely.ab.event.LogEvent.RequestMethod;
@@ -2504,16 +2506,16 @@ public void getFeatureVariableValueReturnsNullWhenVariableTypeDoesNotMatch() thr
25042506
/**
25052507
* Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}
25062508
* returns the String default value of a live variable
2507-
* when the feature is not attached to an experiment.
2509+
* when the feature is not attached to an experiment or a rollout.
25082510
* @throws ConfigParseException
25092511
*/
25102512
@Test
2511-
public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAttached() throws ConfigParseException {
2513+
public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAttachedToExperimentOrRollout() throws ConfigParseException {
25122514
assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString()));
25132515

2514-
String validFeatureKey = FEATURE_SINGLE_VARIABLE_STRING_KEY;
2515-
String validVariableKey = VARIABLE_STRING_VARIABLE_KEY;
2516-
String defaultValue = VARIABLE_STRING_VARIABLE_DEFAULT_VALUE;
2516+
String validFeatureKey = FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY;
2517+
String validVariableKey = VARIABLE_BOOLEAN_VARIABLE_KEY;
2518+
String defaultValue = VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE;
25172519
Map<String, String> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE);
25182520

25192521
Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler)
@@ -2525,28 +2527,41 @@ public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAtt
25252527
validVariableKey,
25262528
genericUserId,
25272529
attributes,
2528-
LiveVariable.VariableType.STRING);
2530+
LiveVariable.VariableType.BOOLEAN);
25292531
assertEquals(defaultValue, value);
25302532

25312533
logbackVerifier.expectMessage(
25322534
Level.INFO,
25332535
"The feature flag \"" + validFeatureKey + "\" is not used in any experiments."
25342536
);
2537+
logbackVerifier.expectMessage(
2538+
Level.INFO,
2539+
"The feature flag \"" + validFeatureKey + "\" is not used in a rollout."
2540+
);
2541+
logbackVerifier.expectMessage(
2542+
Level.INFO,
2543+
"User \"" + genericUserId + "\" was not bucketed into any variation for feature flag \"" +
2544+
validFeatureKey + "\". The default value \"" +
2545+
defaultValue + "\" for \"" +
2546+
validVariableKey + "\" is being returned."
2547+
);
25352548
}
25362549

25372550
/**
25382551
* Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}
25392552
* returns the String default value for a live variable
2540-
* when the feature is attached to an experiment, but the user is excluded from the experiment.
2553+
* when the feature is attached to an experiment and no rollout, but the user is excluded from the experiment.
25412554
* @throws ConfigParseException
25422555
*/
25432556
@Test
25442557
public void getFeatureVariableValueReturnsDefaultValueWhenFeatureIsAttachedToOneExperimentButFailsTargeting() throws ConfigParseException {
25452558
assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString()));
25462559

2547-
String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY;
2548-
String validVariableKey = VARIABLE_FIRST_LETTER_KEY;
2549-
String expectedValue = VARIABLE_FIRST_LETTER_DEFAULT_VALUE;
2560+
String validFeatureKey = FEATURE_SINGLE_VARIABLE_DOUBLE_KEY;
2561+
String validVariableKey = VARIABLE_DOUBLE_VARIABLE_KEY;
2562+
String expectedValue = VARIABLE_DOUBLE_DEFAULT_VALUE;
2563+
FeatureFlag featureFlag = FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE;
2564+
Experiment experiment = validProjectConfig.getExperimentIdMapping().get(featureFlag.getExperimentIds().get(0));
25502565

25512566
Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler)
25522567
.withConfig(validProjectConfig)
@@ -2556,16 +2571,26 @@ public void getFeatureVariableValueReturnsDefaultValueWhenFeatureIsAttachedToOne
25562571
validFeatureKey,
25572572
validVariableKey,
25582573
genericUserId,
2559-
Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, "Slytherin"),
2560-
LiveVariable.VariableType.STRING
2574+
Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, "Ravenclaw"),
2575+
LiveVariable.VariableType.DOUBLE
25612576
);
25622577
assertEquals(expectedValue, valueWithImproperAttributes);
25632578

2579+
logbackVerifier.expectMessage(
2580+
Level.INFO,
2581+
"User \"" + genericUserId + "\" does not meet conditions to be in experiment \"" +
2582+
experiment.getKey() + "\"."
2583+
);
2584+
logbackVerifier.expectMessage(
2585+
Level.INFO,
2586+
"The feature flag \"" + validFeatureKey + "\" is not used in a rollout."
2587+
);
25642588
logbackVerifier.expectMessage(
25652589
Level.INFO,
25662590
"User \"" + genericUserId +
25672591
"\" was not bucketed into any variation for feature flag \"" + validFeatureKey +
2568-
"\". The default value is being returned."
2592+
"\". The default value \"" + expectedValue +
2593+
"\" for \"" + validVariableKey + "\" is being returned."
25692594
);
25702595
}
25712596

0 commit comments

Comments
 (0)