Skip to content

Commit 45c775e

Browse files
authored
Merge pull request #1 from optimizely/josh/persistent-bucketer
Josh/persistent bucketer
2 parents 2a00a73 + 91e0427 commit 45c775e

File tree

10 files changed

+395
-11
lines changed

10 files changed

+395
-11
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
.classpath
1515
.project
1616

17+
# android studio config files
18+
local.properties
19+
1720
**/build
1821
out
1922
classes

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import com.optimizely.ab.annotations.VisibleForTesting;
2020
import com.optimizely.ab.bucketing.Bucketer;
21+
import com.optimizely.ab.bucketing.UserExperimentRecord;
2122
import com.optimizely.ab.config.Attribute;
2223
import com.optimizely.ab.config.EventType;
2324
import com.optimizely.ab.config.Experiment;
@@ -93,6 +94,11 @@ private Optimizely(@Nonnull ProjectConfig projectConfig,
9394
this.errorHandler = errorHandler;
9495
}
9596

97+
// Do work here that should be done once per Optimizely lifecycle
98+
@VisibleForTesting void initialize() {
99+
bucketer.cleanUserExperimentRecords();
100+
}
101+
96102
//======== activate calls ========//
97103

98104
public @Nullable Variation activate(@Nonnull String experimentKey,
@@ -419,6 +425,7 @@ public static class Builder {
419425

420426
private String datafile;
421427
private Bucketer bucketer;
428+
private UserExperimentRecord userExperimentRecord;
422429
private ErrorHandler errorHandler;
423430
private EventHandler eventHandler;
424431
private EventBuilder eventBuilder;
@@ -435,6 +442,11 @@ public Builder withErrorHandler(ErrorHandler errorHandler) {
435442
return this;
436443
}
437444

445+
public Builder withUserExperimentRecord(UserExperimentRecord userExperimentRecord) {
446+
this.userExperimentRecord = userExperimentRecord;
447+
return this;
448+
}
449+
438450
protected Builder withBucketing(Bucketer bucketer) {
439451
this.bucketer = bucketer;
440452
return this;
@@ -445,9 +457,7 @@ protected Builder withEventBuilder(EventBuilder eventBuilder) {
445457
return this;
446458
}
447459

448-
/**
449-
* Helper function for making testing easier
450-
*/
460+
// Helper function for making testing easier
451461
protected Builder withConfig(ProjectConfig projectConfig) {
452462
this.projectConfig = projectConfig;
453463
return this;
@@ -460,7 +470,7 @@ public Optimizely build() {
460470

461471
// use the default bucketer and event builder, if no overrides were provided
462472
if (bucketer == null) {
463-
bucketer = new Bucketer(projectConfig);
473+
bucketer = new Bucketer(projectConfig, userExperimentRecord);
464474
}
465475

466476
if (eventBuilder == null) {
@@ -471,7 +481,9 @@ public Optimizely build() {
471481
errorHandler = new NoOpErrorHandler();
472482
}
473483

474-
return new Optimizely(projectConfig, bucketer, eventHandler, eventBuilder, errorHandler);
484+
Optimizely optimizely = new Optimizely(projectConfig, bucketer, eventHandler, eventBuilder, errorHandler);
485+
optimizely.initialize();
486+
return optimizely;
475487
}
476488
}
477489
}

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

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public class Bucketer {
4747

4848
private final ProjectConfig projectConfig;
4949

50+
@Nullable private final UserExperimentRecord userExperimentRecord;
51+
5052
private static final Logger logger = LoggerFactory.getLogger(Bucketer.class);
5153

5254
private static final int MURMUR_HASH_SEED = 1;
@@ -58,7 +60,12 @@ public class Bucketer {
5860
static final int MAX_TRAFFIC_VALUE = 10000;
5961

6062
public Bucketer(ProjectConfig projectConfig) {
63+
this(projectConfig, null);
64+
}
65+
66+
public Bucketer(ProjectConfig projectConfig, @Nullable UserExperimentRecord userExperimentRecord) {
6167
this.projectConfig = projectConfig;
68+
this.userExperimentRecord = userExperimentRecord;
6269
}
6370

6471
private String bucketToEntity(int bucketValue, List<TrafficAllocation> trafficAllocations) {
@@ -101,8 +108,29 @@ private Experiment bucketToExperiment(@Nonnull Group group,
101108
private Variation bucketToVariation(@Nonnull Experiment experiment,
102109
@Nonnull String userId) {
103110
// "salt" the bucket id using the experiment id
104-
String combinedBucketId = userId + experiment.getId();
111+
String experimentId = experiment.getId();
105112
String experimentKey = experiment.getKey();
113+
String combinedBucketId = userId + experimentId;
114+
115+
// If a user experiment record instance is present then check it for a saved variation
116+
if (userExperimentRecord != null) {
117+
String variationKey = userExperimentRecord.lookup(userId, experimentKey);
118+
if (variationKey != null) {
119+
logger.info("Returning previously activated variation \"{}\" of experiment \"{}\" "
120+
+ "for user \"{}\" from user experiment record.",
121+
variationKey, experimentKey, userId);
122+
// A variation is stored for this combined bucket id
123+
return projectConfig
124+
.getExperimentIdMapping()
125+
.get(experimentId)
126+
.getVariationKeyToVariationMap()
127+
.get(variationKey);
128+
} else {
129+
logger.info("No previously activated variation of experiment \"{}\" "
130+
+ "for user \"{}\" found in user experiment record.",
131+
experimentKey, userId);
132+
}
133+
}
106134

107135
List<TrafficAllocation> trafficAllocations = experiment.getTrafficAllocation();
108136

@@ -113,8 +141,22 @@ private Variation bucketToVariation(@Nonnull Experiment experiment,
113141
String bucketedVariationId = bucketToEntity(bucketValue, trafficAllocations);
114142
if (bucketedVariationId != null) {
115143
Variation bucketedVariation = experiment.getVariationIdToVariationMap().get(bucketedVariationId);
116-
logger.info("User \"{}\" is in variation \"{}\" of experiment \"{}\".", userId, bucketedVariation.getKey(),
144+
String variationKey = bucketedVariation.getKey();
145+
logger.info("User \"{}\" is in variation \"{}\" of experiment \"{}\".", userId, variationKey,
117146
experimentKey);
147+
148+
// If a user experiment record is present give it a variation to store
149+
if (userExperimentRecord != null) {
150+
boolean saved = userExperimentRecord.save(userId, experiment.getKey(), variationKey);
151+
if (saved) {
152+
logger.info("Saved variation \"{}\" of experiment \"{}\" for user \"{}\".",
153+
variationKey, experimentKey, userId);
154+
} else {
155+
logger.warn("Failed to save variation \"{}\" of experiment \"{}\" for user \"{}\".",
156+
variationKey, experimentKey, userId);
157+
}
158+
}
159+
118160
return bucketedVariation;
119161
}
120162

@@ -180,4 +222,29 @@ int generateBucketValue(int hashCode) {
180222
double ratio = (double)(hashCode & 0xFFFFFFFFL) / Math.pow(2, 32);
181223
return (int)Math.floor(MAX_TRAFFIC_VALUE * ratio);
182224
}
225+
226+
@Nullable
227+
public UserExperimentRecord getUserExperimentRecord() {
228+
return userExperimentRecord;
229+
}
230+
231+
/**
232+
* Gives implementations of {@link UserExperimentRecord} a chance to remove records
233+
* of experiments that are deleted or not running.
234+
*/
235+
public void cleanUserExperimentRecords() {
236+
if (userExperimentRecord != null) {
237+
Map<String, Map<String,String>> records = userExperimentRecord.getAllRecords();
238+
if (records != null) {
239+
for (Map.Entry<String,Map<String,String>> record : records.entrySet()) {
240+
for (String experimentKey : record.getValue().keySet()) {
241+
Experiment experiment = projectConfig.getExperimentKeyMapping().get(experimentKey);
242+
if (experiment == null || !experiment.isRunning()) {
243+
userExperimentRecord.remove(record.getKey(), experimentKey);
244+
}
245+
}
246+
}
247+
}
248+
}
249+
}
183250
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
*
3+
* Copyright 2016, Optimizely
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package com.optimizely.ab.bucketing;
18+
19+
import java.util.Map;
20+
21+
/**
22+
* Gives implementors a chance to override {@link Bucketer} bucketing on subsequent activations.
23+
*
24+
* Overriding bucketing for subsequent activations is useful in order to prevent changes to
25+
* user experience after changing traffic allocations. Also, this interface gives users
26+
* a hook to keep track of activation history.
27+
*/
28+
public interface UserExperimentRecord {
29+
30+
/**
31+
* Called when implementors should save an activation
32+
*
33+
* @param userId the user id of the activation
34+
* @param experimentKey the experiment key of the activation
35+
* @param variationKey the variation key of the activation
36+
* @return true if saving of the record was successful
37+
*/
38+
boolean save(String userId, String experimentKey, String variationKey);
39+
40+
/**
41+
* Called by the bucketer to check for a record of the previous activation
42+
*
43+
* @param userId the user is id of the next activation
44+
* @param experimentKey the experiment id of the next activation
45+
* @return the variation key of the next activation, or null if no record exists
46+
*/
47+
String lookup(String userId, String experimentKey);
48+
49+
/**
50+
* Called when user experiment record should be removed
51+
*
52+
* Records should be removed when an experiment is not running or when an experiment has been
53+
* deleted.
54+
*
55+
* @param userId the user id of the record to remove
56+
* @param experimentKey the experiment key of the record to remove
57+
* @return true if the record was removed
58+
*/
59+
boolean remove(String userId, String experimentKey);
60+
61+
/**
62+
* Called by bucketer to get a mapping of all stored records
63+
*
64+
* This mapping is needed so that the bucketer can {@link #remove(String, String)} outdated
65+
* records.
66+
* @return a map of userIds to a map of experiment keys to variation keys
67+
*/
68+
Map<String, Map<String,String>> getAllRecords();
69+
70+
}

core-api/src/main/java/com/optimizely/ab/event/EventHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import java.util.Map;
2020

2121
/**
22-
* Implementations are responsible for dispatching {@link LogEvent}s to the Optimizely event end-point.
22+
* Implementations are responsible for dispatching event's to the Optimizely event end-point.
2323
*/
2424
public interface EventHandler {
2525

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717
package com.optimizely.ab;
1818

19+
import com.optimizely.ab.bucketing.UserExperimentRecord;
1920
import com.optimizely.ab.config.ProjectConfigTestUtils;
2021
import com.optimizely.ab.error.ErrorHandler;
2122
import com.optimizely.ab.error.NoOpErrorHandler;
@@ -35,6 +36,7 @@
3536
import static org.hamcrest.CoreMatchers.instanceOf;
3637
import static org.hamcrest.CoreMatchers.is;
3738
import static org.junit.Assert.assertThat;
39+
import static org.mockito.Mockito.mock;
3840

3941
/**
4042
* Tests for {@link Optimizely#builder(String, EventHandler)}.
@@ -84,4 +86,14 @@ public void withDefaultErrorHandler() throws Exception {
8486

8587
assertThat(optimizelyClient.errorHandler, instanceOf(NoOpErrorHandler.class));
8688
}
89+
90+
@Test
91+
public void withUserExperimentRecord() throws Exception {
92+
UserExperimentRecord userExperimentRecord = mock(UserExperimentRecord.class);
93+
Optimizely optimizelyClient = Optimizely.builder(validConfigJson(), mockEventHandler)
94+
.withUserExperimentRecord(userExperimentRecord)
95+
.build();
96+
97+
assertThat(optimizelyClient.bucketer.getUserExperimentRecord(), is(userExperimentRecord));
98+
}
8799
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,26 @@ public void activateEndToEnd() throws Exception {
138138
verify(mockEventHandler).dispatchEvent("test_url", testParams);
139139
}
140140

141+
/**
142+
* Verify that bucketer initialization happens after building the Optimizely object
143+
* @throws Exception
144+
*/
145+
@Test
146+
public void initializationOccursForBucketerWhenBuildingOptly() throws Exception {
147+
String datafile = validConfigJson();
148+
ProjectConfig projectConfig = validProjectConfig();
149+
EventBuilder mockEventBuilder = mock(EventBuilder.class);
150+
151+
Optimizely.builder(datafile, mockEventHandler)
152+
.withBucketing(mockBucketer)
153+
.withEventBuilder(mockEventBuilder)
154+
.withConfig(projectConfig)
155+
.withErrorHandler(mockErrorHandler)
156+
.build();
157+
158+
verify(mockBucketer).cleanUserExperimentRecords();
159+
}
160+
141161
/**
142162
* Verify that the {@link Optimizely#activate(Experiment, String, Map)} DOES NOT dispatch an impression event
143163
* when the user isn't bucketed to a variation.

0 commit comments

Comments
 (0)