Skip to content

Commit d7687c8

Browse files
author
Josh Deffibaugh
committed
Changes persistent bucketer to user experiment record
1 parent 6ba9b55 commit d7687c8

File tree

8 files changed

+208
-97
lines changed

8 files changed

+208
-97
lines changed

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

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

1919
import com.optimizely.ab.annotations.VisibleForTesting;
2020
import com.optimizely.ab.bucketing.Bucketer;
21-
import com.optimizely.ab.bucketing.PersistentBucketer;
21+
import com.optimizely.ab.bucketing.UserExperimentRecord;
2222
import com.optimizely.ab.config.Attribute;
2323
import com.optimizely.ab.config.EventType;
2424
import com.optimizely.ab.config.Experiment;
@@ -94,6 +94,13 @@ private Optimizely(@Nonnull ProjectConfig projectConfig,
9494
this.errorHandler = errorHandler;
9595
}
9696

97+
// Do work here that should be done once per Optimizely lifecycle
98+
@VisibleForTesting void initialize() {
99+
// Give users of ExperimentRegistry a chance to clean out old state for experiments
100+
// that are no longer in the data file.
101+
bucketer.cleanUserExperimentRecord();
102+
}
103+
97104
//======== activate calls ========//
98105

99106
public @Nullable Variation activate(@Nonnull String experimentKey,
@@ -420,7 +427,7 @@ public static class Builder {
420427

421428
private String datafile;
422429
private Bucketer bucketer;
423-
private PersistentBucketer persistentBucketer;
430+
private UserExperimentRecord userExperimentRecord;
424431
private ErrorHandler errorHandler;
425432
private EventHandler eventHandler;
426433
private EventBuilder eventBuilder;
@@ -437,8 +444,8 @@ public Builder withErrorHandler(ErrorHandler errorHandler) {
437444
return this;
438445
}
439446

440-
public Builder withPersistentBucketer(PersistentBucketer persistentBucketer) {
441-
this.persistentBucketer = persistentBucketer;
447+
public Builder withUserExperimentRecord(UserExperimentRecord userExperimentRecord) {
448+
this.userExperimentRecord = userExperimentRecord;
442449
return this;
443450
}
444451

@@ -467,7 +474,7 @@ public Optimizely build() {
467474

468475
// use the default bucketer and event builder, if no overrides were provided
469476
if (bucketer == null) {
470-
bucketer = new Bucketer(projectConfig, persistentBucketer);
477+
bucketer = new Bucketer(projectConfig, userExperimentRecord);
471478
}
472479

473480
if (eventBuilder == null) {
@@ -478,7 +485,9 @@ public Optimizely build() {
478485
errorHandler = new NoOpErrorHandler();
479486
}
480487

481-
return new Optimizely(projectConfig, bucketer, eventHandler, eventBuilder, errorHandler);
488+
Optimizely optimizely = new Optimizely(projectConfig, bucketer, eventHandler, eventBuilder, errorHandler);
489+
optimizely.initialize();
490+
return optimizely;
482491
}
483492
}
484493
}

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

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public class Bucketer {
4747

4848
private final ProjectConfig projectConfig;
4949

50-
@Nullable private final PersistentBucketer persistentBucketer;
50+
@Nullable private final UserExperimentRecord userExperimentRecord;
5151

5252
private static final Logger logger = LoggerFactory.getLogger(Bucketer.class);
5353

@@ -63,9 +63,9 @@ public Bucketer(ProjectConfig projectConfig) {
6363
this(projectConfig, null);
6464
}
6565

66-
public Bucketer(ProjectConfig projectConfig, @Nullable PersistentBucketer persistentBucketer) {
66+
public Bucketer(ProjectConfig projectConfig, @Nullable UserExperimentRecord userExperimentRecord) {
6767
this.projectConfig = projectConfig;
68-
this.persistentBucketer = persistentBucketer;
68+
this.userExperimentRecord = userExperimentRecord;
6969
}
7070

7171
private String bucketToEntity(int bucketValue, List<TrafficAllocation> trafficAllocations) {
@@ -111,12 +111,12 @@ private Variation bucketToVariation(@Nonnull Experiment experiment,
111111
String combinedBucketId = userId + experiment.getId();
112112

113113
// If a persistent bucketer instance is present then check it for a persisted variation
114-
if (persistentBucketer != null) {
115-
Variation variation = persistentBucketer.restoreActivation(projectConfig, userId, experiment);
116-
if (variation != null) {
117-
logger.info("Returning previously activated variation \"{}\" from persistent bucketer.", variation.getKey());
114+
if (userExperimentRecord != null) {
115+
String variationKey = userExperimentRecord.lookup(userId, experiment.getKey());
116+
if (variationKey != null) {
117+
logger.info("Returning previously activated variation \"{}\" from user experiment registry.", variationKey);
118118
// A variation is stored for this combined bucket id
119-
return variation;
119+
return projectConfig.getExperimentIdMapping().get(experiment.getId()).getVariationKeyToVariationMap().get(variationKey);
120120
} else {
121121
logger.info("No previously activated variation returned from persistent bucketer.");
122122
}
@@ -137,8 +137,8 @@ private Variation bucketToVariation(@Nonnull Experiment experiment,
137137
experimentKey);
138138

139139
// If a persistent bucketer instance is present give it a variation to store
140-
if (persistentBucketer != null) {
141-
boolean saved = persistentBucketer.saveActivation(userId, experiment, bucketedVariation);
140+
if (userExperimentRecord != null) {
141+
boolean saved = userExperimentRecord.save(userId, experiment.getKey(), bucketedVariation.getKey());
142142
if (saved) {
143143
logger.info("Persisted variation \"{}\" of experiment \"{}\".", bucketedVariation.getKey(), experimentKey);
144144
} else {
@@ -213,7 +213,23 @@ int generateBucketValue(int hashCode) {
213213
}
214214

215215
@Nullable
216-
public PersistentBucketer getPersistentBucketer() {
217-
return persistentBucketer;
216+
public UserExperimentRecord getUserExperimentRecord() {
217+
return userExperimentRecord;
218+
}
219+
220+
public void cleanUserExperimentRecord() {
221+
if (userExperimentRecord != null) {
222+
Map<String,Map<String,String>> records = userExperimentRecord.records();
223+
if (records != null) {
224+
for (Map.Entry<String,Map<String,String>> record : records.entrySet()) {
225+
for (String experimentKey : record.getValue().keySet()) {
226+
Experiment experiment = projectConfig.getExperimentKeyMapping().get(experimentKey);
227+
if (experiment == null || !experiment.isRunning()) {
228+
userExperimentRecord.remove(record.getKey(), experimentKey);
229+
}
230+
}
231+
}
232+
}
233+
}
218234
}
219235
}

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

Lines changed: 0 additions & 54 deletions
This file was deleted.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.optimizely.ab.bucketing;
2+
3+
import java.util.Map;
4+
5+
/**
6+
* Gives implementors a chance to override {@link Bucketer} bucketing on subsequent activations.
7+
*
8+
* Overriding bucketing for subsequent activations is useful in order to prevent changes to
9+
* user experience after changing traffic allocations. Also, this interface gives users
10+
* a hook to keep track of activation history.
11+
*/
12+
public interface UserExperimentRecord {
13+
14+
/**
15+
* Called when implementors should save an activation
16+
*
17+
* @param userId the user id of the activation
18+
* @param experimentKey the experiment key of the activation
19+
* @param variationKey the variation key of the activation
20+
* @return true if saving of the record was successful
21+
*/
22+
boolean save(String userId, String experimentKey, String variationKey);
23+
24+
/**
25+
* Called by the bucketer to check for a record of the previous activation
26+
* @param userId the user is id of the next activation
27+
* @param experimentKey the experiment id of the next activation
28+
* @return the variation key of the next activation, or null if no record exists
29+
*/
30+
String lookup(String userId, String experimentKey);
31+
32+
/**
33+
* Called when user experiment record should be removed
34+
*
35+
* Records should be removed when an experiment is not running or when an experiment has been
36+
* deleted.
37+
*
38+
* @param userId the user id of the record to remove
39+
* @param experimentKey the experiment key of the record to remove
40+
* @return true if the record was removed
41+
*/
42+
boolean remove(String userId, String experimentKey);
43+
44+
/**
45+
* Called by bucketer to get a mapping of all stored records
46+
*
47+
* This mapping is needed so that the bucketer can {@link this#remove(String, String)} outdated
48+
* records.
49+
* @return a map of userIds to a map of experiment keys to variation keys
50+
*/
51+
Map<String,Map<String,String>> records();
52+
53+
}

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

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

19-
import com.optimizely.ab.bucketing.PersistentBucketer;
19+
import com.optimizely.ab.bucketing.UserExperimentRecord;
2020
import com.optimizely.ab.config.ProjectConfigTestUtils;
2121
import com.optimizely.ab.error.ErrorHandler;
2222
import com.optimizely.ab.error.NoOpErrorHandler;
@@ -89,11 +89,11 @@ public void withDefaultErrorHandler() throws Exception {
8989

9090
@Test
9191
public void withPersistentBucketer() throws Exception {
92-
PersistentBucketer persistentBucketer = mock(PersistentBucketer.class);
92+
UserExperimentRecord userExperimentRecord = mock(UserExperimentRecord.class);
9393
Optimizely optimizelyClient = Optimizely.builder(validConfigJson(), mockEventHandler)
94-
.withPersistentBucketer(persistentBucketer)
94+
.withUserExperimentRecord(userExperimentRecord)
9595
.build();
9696

97-
assertThat(optimizelyClient.bucketer.getPersistentBucketer(), is(persistentBucketer));
97+
assertThat(optimizelyClient.bucketer.getUserExperimentRecord(), is(userExperimentRecord));
9898
}
9999
}

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).cleanUserExperimentRecord();
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)