Skip to content

Commit 1efeecb

Browse files
authored
Partition Bucketer.bucket() (#89)
* break up bucketing into separate parts 1. `getForcedVariation` 2. `getStoredVariation` 3. `bucket`
1 parent dc464bf commit 1efeecb

File tree

8 files changed

+281
-240
lines changed

8 files changed

+281
-240
lines changed

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,26 @@ public void track(@Nonnull String eventName,
464464
return null;
465465
}
466466

467-
return bucketer.bucket(experiment, userId);
467+
Variation variation;
468+
469+
// check for whitelisting
470+
variation = bucketer.getForcedVariation(experiment, userId);
471+
if (variation != null) {
472+
return variation;
473+
}
474+
475+
// check if user exists in user profile
476+
variation = bucketer.getStoredVariation(experiment, userId);
477+
if (variation != null) {
478+
return variation;
479+
}
480+
481+
if (ProjectValidationUtils.isUserInExperiment(projectConfig, experiment, attributes)) {
482+
return bucketer.bucket(experiment, userId);
483+
}
484+
logger.info("User \"{}\" does not meet conditions to be in experiment \"{}\".", userId, experiment.getKey());
485+
486+
return null;
468487
}
469488

470489
/**

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

Lines changed: 67 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -131,48 +131,15 @@ private Variation bucketToVariation(@Nonnull Experiment experiment,
131131
return null;
132132
}
133133

134+
/**
135+
* Assign a {@link Variation} of an {@link Experiment} to a user based on hashed value from murmurhash3.
136+
* @param experiment The Experiment in which the user is to be bucketed.
137+
* @param userId User Identifier
138+
* @return Variation the user is bucketed into or null.
139+
*/
134140
public @Nullable Variation bucket(@Nonnull Experiment experiment,
135141
@Nonnull String userId) {
136-
137-
// ---------- Check whitelist ----------
138-
// if a user has a forced variation mapping, return the respective variation
139-
Map<String, String> userIdToVariationKeyMap = experiment.getUserIdToVariationKeyMap();
140-
if (userIdToVariationKeyMap.containsKey(userId)) {
141-
String forcedVariationKey = userIdToVariationKeyMap.get(userId);
142-
Variation forcedVariation = experiment.getVariationKeyToVariationMap().get(forcedVariationKey);
143-
if (forcedVariation != null) {
144-
logger.info("User \"{}\" is forced in variation \"{}\".", userId, forcedVariationKey);
145-
} else {
146-
logger.error("Variation \"{}\" is not in the datafile. Not activating user \"{}\".", forcedVariationKey,
147-
userId);
148-
}
149-
150-
return forcedVariation;
151-
}
152-
153-
// ---------- Check User Profile for Sticky Bucketing ----------
154-
// If a user profile instance is present then check it for a saved variation
155142
String experimentId = experiment.getId();
156-
String experimentKey = experiment.getKey();
157-
if (userProfile != null) {
158-
String variationId = userProfile.lookup(userId, experimentId);
159-
if (variationId != null) {
160-
Variation savedVariation = projectConfig
161-
.getExperimentIdMapping()
162-
.get(experimentId)
163-
.getVariationIdToVariationMap()
164-
.get(variationId);
165-
logger.info("Returning previously activated variation \"{}\" of experiment \"{}\" "
166-
+ "for user \"{}\" from user profile.",
167-
savedVariation.getKey(), experimentKey, userId);
168-
// A variation is stored for this combined bucket id
169-
return savedVariation;
170-
} else {
171-
logger.info("No previously activated variation of experiment \"{}\" "
172-
+ "for user \"{}\" found in user profile.",
173-
experimentKey, userId);
174-
}
175-
}
176143

177144
// ---------- Bucket User ----------
178145
String groupId = experiment.getGroupId();
@@ -217,6 +184,67 @@ private Variation bucketToVariation(@Nonnull Experiment experiment,
217184
return bucketedVariation;
218185
}
219186

187+
/**
188+
* Get the variation the user has been whitelisted into.
189+
* @param experiment {@link Experiment} in which user is to be bucketed.
190+
* @param userId User Identifier
191+
* @return null if the user is not whitelisted into any variation
192+
* {@link Variation} the user is bucketed into if the user has a specified whitelisted variation.
193+
*/
194+
public @Nullable Variation getForcedVariation(@Nonnull Experiment experiment, @Nonnull String userId) {
195+
// if a user has a forced variation mapping, return the respective variation
196+
Map<String, String> userIdToVariationKeyMap = experiment.getUserIdToVariationKeyMap();
197+
if (userIdToVariationKeyMap.containsKey(userId)) {
198+
String forcedVariationKey = userIdToVariationKeyMap.get(userId);
199+
Variation forcedVariation = experiment.getVariationKeyToVariationMap().get(forcedVariationKey);
200+
if (forcedVariation != null) {
201+
logger.info("User \"{}\" is forced in variation \"{}\".", userId, forcedVariationKey);
202+
} else {
203+
logger.error("Variation \"{}\" is not in the datafile. Not activating user \"{}\".", forcedVariationKey,
204+
userId);
205+
}
206+
207+
return forcedVariation;
208+
}
209+
210+
return null;
211+
}
212+
213+
/**
214+
* Get the {@link Variation} that has been stored for the user in the {@link UserProfile} implementation.
215+
* @param experiment {@link Experiment} in which the user was bucketed.
216+
* @param userId User Identifier
217+
* @return null if the {@link UserProfile} implementation is null or the user was not previously bucketed.
218+
* else return the {@link Variation} the user was previously bucketed into.
219+
*/
220+
public @Nullable Variation getStoredVariation(@Nonnull Experiment experiment, @Nonnull String userId) {
221+
// ---------- Check User Profile for Sticky Bucketing ----------
222+
// If a user profile instance is present then check it for a saved variation
223+
String experimentId = experiment.getId();
224+
String experimentKey = experiment.getKey();
225+
if (userProfile != null) {
226+
String variationId = userProfile.lookup(userId, experimentId);
227+
if (variationId != null) {
228+
Variation savedVariation = projectConfig
229+
.getExperimentIdMapping()
230+
.get(experimentId)
231+
.getVariationIdToVariationMap()
232+
.get(variationId);
233+
logger.info("Returning previously activated variation \"{}\" of experiment \"{}\" "
234+
+ "for user \"{}\" from user profile.",
235+
savedVariation.getKey(), experimentKey, userId);
236+
// A variation is stored for this combined bucket id
237+
return savedVariation;
238+
} else {
239+
logger.info("No previously activated variation of experiment \"{}\" "
240+
+ "for user \"{}\" found in user profile.",
241+
experimentKey, userId);
242+
}
243+
}
244+
245+
return null;
246+
}
247+
220248
//======== Helper methods ========//
221249

222250
/**

core-api/src/main/java/com/optimizely/ab/internal/ProjectValidationUtils.java

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,6 @@ public static boolean validatePreconditions(ProjectConfig projectConfig, UserPro
5151
return false;
5252
}
5353

54-
if (experiment.getUserIdToVariationKeyMap().containsKey(userId)) {
55-
return true;
56-
}
57-
58-
if (userProfile != null && userProfile.lookup(userId, experiment.getId()) != null) {
59-
return true;
60-
}
61-
62-
if (!isUserInExperiment(projectConfig, experiment, attributes)) {
63-
logger.info("User \"{}\" does not meet conditions to be in experiment \"{}\".", userId, experiment.getKey());
64-
return false;
65-
}
66-
6754
return true;
6855
}
6956

@@ -75,7 +62,7 @@ public static boolean validatePreconditions(ProjectConfig projectConfig, UserPro
7562
* @param attributes the attributes of the user
7663
* @return whether the user meets the criteria for the experiment
7764
*/
78-
private static boolean isUserInExperiment(ProjectConfig projectConfig, Experiment experiment,
65+
public static boolean isUserInExperiment(ProjectConfig projectConfig, Experiment experiment,
7966
Map<String, String> attributes) {
8067
List<String> experimentAudienceIds = experiment.getAudienceIds();
8168

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

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919
import ch.qos.logback.classic.Level;
2020
import com.google.common.collect.ImmutableMap;
2121
import com.optimizely.ab.bucketing.Bucketer;
22+
import com.optimizely.ab.bucketing.UserProfile;
2223
import com.optimizely.ab.config.Attribute;
2324
import com.optimizely.ab.config.EventType;
2425
import com.optimizely.ab.config.Experiment;
2526
import com.optimizely.ab.config.ProjectConfig;
2627
import com.optimizely.ab.config.TrafficAllocation;
2728
import com.optimizely.ab.config.Variation;
29+
import com.optimizely.ab.config.parser.ConfigParseException;
2830
import com.optimizely.ab.error.ErrorHandler;
2931
import com.optimizely.ab.error.NoOpErrorHandler;
3032
import com.optimizely.ab.error.RaiseExceptionErrorHandler;
@@ -62,6 +64,7 @@
6264
import static org.hamcrest.MatcherAssert.assertThat;
6365
import static org.hamcrest.Matchers.hasEntry;
6466
import static org.hamcrest.Matchers.hasKey;
67+
import static org.junit.Assert.assertEquals;
6568
import static org.junit.Assert.assertNotNull;
6669
import static org.junit.Assert.assertNull;
6770
import static org.mockito.Matchers.any;
@@ -71,6 +74,7 @@
7174
import static org.mockito.Mockito.doThrow;
7275
import static org.mockito.Mockito.mock;
7376
import static org.mockito.Mockito.never;
77+
import static org.mockito.Mockito.spy;
7478
import static org.mockito.Mockito.verify;
7579
import static org.mockito.Mockito.when;
7680

@@ -1450,6 +1454,30 @@ public void trackDispatchesWhenEventHasLaunchedAndRunningExperiments() throws Ex
14501454
verify(client.eventHandler).dispatchEvent(eq(conversionEvent));
14511455
}
14521456

1457+
1458+
1459+
/**
1460+
* Verify that an event is not dispatched if a user doesn't satisfy audience conditions for an experiment.
1461+
*/
1462+
@Test
1463+
public void trackDoesNotSendEventWhenUserDoesNotSatisfyAudiences() throws Exception {
1464+
Attribute attribute = validProjectConfig.getAttributes().get(0);
1465+
EventType eventType = validProjectConfig.getEventTypes().get(2);
1466+
1467+
// the audience for the experiments is "NOT firefox" so this user shouldn't satisfy audience conditions
1468+
Map<String, String> attributeMap = Collections.singletonMap(attribute.getKey(), "firefox");
1469+
1470+
Optimizely client = Optimizely.builder(validDatafile, mockEventHandler)
1471+
.withConfig(validProjectConfig)
1472+
.build();
1473+
1474+
logbackVerifier.expectMessage(Level.INFO, "There are no valid experiments for event \"" + eventType.getKey()
1475+
+ "\" to track.");
1476+
1477+
client.track(eventType.getKey(), genericUserId, attributeMap);
1478+
verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class));
1479+
}
1480+
14531481
//======== getVariation tests ========//
14541482

14551483
/**
@@ -1604,6 +1632,37 @@ public void getVariationNoAudiences() throws Exception {
16041632
assertThat(actualVariation, is(bucketedVariation));
16051633
}
16061634

1635+
/**
1636+
* Verify that {@link Optimizely#getVariation(Experiment, String, Map)}
1637+
* gives precedence to user profile over audience evaluation.
1638+
*/
1639+
@Test
1640+
public void getVariationEvaluatesUserProfileBeforeAudienceTargeting() throws ConfigParseException {
1641+
Experiment experiment = validProjectConfig.getExperiments().get(0);
1642+
Variation storedVariation = experiment.getVariations().get(0);
1643+
String userProfileUserId = "userProfileId";
1644+
1645+
UserProfile mockedUserProfile = mock(UserProfile.class);
1646+
when(mockedUserProfile.lookup(userProfileUserId, experiment.getId())).thenReturn(storedVariation.getId());
1647+
1648+
Optimizely client = Optimizely.builder(validDatafile, mockEventHandler)
1649+
.withConfig(validProjectConfig)
1650+
.withUserProfile(mockedUserProfile)
1651+
.build();
1652+
assertNotNull(client);
1653+
1654+
// ensure that normal users still get excluded from the experiment when they fail audience evaluation
1655+
assertNull(client.getVariation(experiment, genericUserId, Collections.<String, String>emptyMap()));
1656+
1657+
logbackVerifier.expectMessage(Level.INFO,
1658+
"User \"" + genericUserId + "\" does not meet conditions to be in experiment \""
1659+
+ experiment.getKey() + "\".");
1660+
1661+
// ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation
1662+
assertEquals(storedVariation,
1663+
client.getVariation(experiment, userProfileUserId, Collections.<String, String>emptyMap()));
1664+
}
1665+
16071666
/**
16081667
* Verify that {@link Optimizely#getVariation(String, String)} handles the case where an unknown experiment
16091668
* (i.e., not in the config) is passed through and a {@link RaiseExceptionErrorHandler} is provided.
@@ -1685,24 +1744,31 @@ public void getVariationForGroupExperimentWithNonMatchingAttributes() throws Exc
16851744

16861745
/**
16871746
* Verify that {@link Optimizely#getVariation(String, String, Map)} gives precedence to forced variation bucketing
1688-
* over audience evaluation.
1747+
* over user profile and audience evaluation for murmurhash3 bucketing.
16891748
*/
16901749
@Test
1691-
public void getVariationForcedVariationPrecedesAudienceEval() throws Exception {
1750+
public void getVariationForcedVariationPrecedesUserProfileAndAudienceEval() throws Exception {
1751+
Bucketer bucketer = spy(new Bucketer(validProjectConfig));
16921752
Experiment experiment = validProjectConfig.getExperiments().get(0);
16931753
Variation expectedVariation = experiment.getVariations().get(0);
1754+
String whitelistedUserId = "testUser1";
16941755

16951756
Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler)
1696-
.withConfig(validProjectConfig)
1697-
.build();
1757+
.withBucketing(bucketer)
1758+
.withConfig(validProjectConfig)
1759+
.build();
16981760

16991761
// user excluded without audiences and whitelisting
17001762
assertNull(optimizely.getVariation(experiment.getKey(), genericUserId));
17011763

1702-
logbackVerifier.expectMessage(Level.INFO, "User \"testUser1\" is forced in variation \"vtag1\".");
1764+
logbackVerifier.expectMessage(Level.INFO, "User \"" + whitelistedUserId + "\" is forced in variation \"vtag1\".");
17031765

17041766
// no attributes provided for a experiment that has an audience
1705-
assertThat(optimizely.getVariation(experiment.getKey(), "testUser1"), is(expectedVariation));
1767+
assertThat(optimizely.getVariation(experiment.getKey(), whitelistedUserId), is(expectedVariation));
1768+
1769+
verify(bucketer).getForcedVariation(experiment, whitelistedUserId);
1770+
verify(bucketer, never()).getStoredVariation(experiment, whitelistedUserId);
1771+
verify(bucketer, never()).bucket(experiment, whitelistedUserId);
17061772
}
17071773

17081774
/**

0 commit comments

Comments
 (0)