Skip to content

Commit 20c1cd0

Browse files
author
Josh Deffibaugh
committed
Adds persisten buckter interface and hooks it up to buckter via composition
1 parent 4b7fe39 commit 20c1cd0

File tree

5 files changed

+215
-1
lines changed

5 files changed

+215
-1
lines changed

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

Lines changed: 8 additions & 1 deletion
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.PersistentBucketer;
2122
import com.optimizely.ab.config.Attribute;
2223
import com.optimizely.ab.config.EventType;
2324
import com.optimizely.ab.config.Experiment;
@@ -419,6 +420,7 @@ public static class Builder {
419420

420421
private String datafile;
421422
private Bucketer bucketer;
423+
private PersistentBucketer persistentBucketer;
422424
private ErrorHandler errorHandler;
423425
private EventHandler eventHandler;
424426
private EventBuilder eventBuilder;
@@ -435,6 +437,11 @@ public Builder withErrorHandler(ErrorHandler errorHandler) {
435437
return this;
436438
}
437439

440+
public Builder withPersistentBucketer(PersistentBucketer persistentBucketer) {
441+
this.persistentBucketer = persistentBucketer;
442+
return this;
443+
}
444+
438445
protected Builder withBucketing(Bucketer bucketer) {
439446
this.bucketer = bucketer;
440447
return this;
@@ -460,7 +467,7 @@ public Optimizely build() {
460467

461468
// use the default bucketer and event builder, if no overrides were provided
462469
if (bucketer == null) {
463-
bucketer = new Bucketer(projectConfig);
470+
bucketer = new Bucketer(projectConfig, persistentBucketer);
464471
}
465472

466473
if (eventBuilder == null) {

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

Lines changed: 36 additions & 0 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 PersistentBucketer persistentBucketer;
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 PersistentBucketer persistentBucketer) {
6167
this.projectConfig = projectConfig;
68+
this.persistentBucketer = persistentBucketer;
6269
}
6370

6471
private String bucketToEntity(int bucketValue, List<TrafficAllocation> trafficAllocations) {
@@ -102,6 +109,19 @@ private Variation bucketToVariation(@Nonnull Experiment experiment,
102109
@Nonnull String userId) {
103110
// "salt" the bucket id using the experiment id
104111
String combinedBucketId = userId + experiment.getId();
112+
113+
// 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());
118+
// A variation is stored for this combined bucket id
119+
return variation;
120+
} else {
121+
logger.info("No previously activated variation returned from persistent bucketer.");
122+
}
123+
}
124+
105125
String experimentKey = experiment.getKey();
106126

107127
List<TrafficAllocation> trafficAllocations = experiment.getTrafficAllocation();
@@ -115,6 +135,17 @@ private Variation bucketToVariation(@Nonnull Experiment experiment,
115135
Variation bucketedVariation = experiment.getVariationIdToVariationMap().get(bucketedVariationId);
116136
logger.info("User \"{}\" is in variation \"{}\" of experiment \"{}\".", userId, bucketedVariation.getKey(),
117137
experimentKey);
138+
139+
// If a persistent bucketer instance is present give it a variation to store
140+
if (persistentBucketer != null) {
141+
boolean saved = persistentBucketer.saveActivation(projectConfig, userId, experiment, bucketedVariation);
142+
if (saved) {
143+
logger.info("Persisted variation \"{}\" of experiment \"{}\".", bucketedVariation.getKey(), experimentKey);
144+
} else {
145+
logger.warn("Failed to persist variation \"{}\" of experiment \"{}\".", bucketedVariation.getKey(), experimentKey);
146+
}
147+
}
148+
118149
return bucketedVariation;
119150
}
120151

@@ -180,4 +211,9 @@ int generateBucketValue(int hashCode) {
180211
double ratio = (double)(hashCode & 0xFFFFFFFFL) / Math.pow(2, 32);
181212
return (int)Math.floor(MAX_TRAFFIC_VALUE * ratio);
182213
}
214+
215+
@Nullable
216+
public PersistentBucketer getPersistentBucketer() {
217+
return persistentBucketer;
218+
}
183219
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.optimizely.ab.bucketing;
2+
3+
import com.optimizely.ab.Optimizely;
4+
import com.optimizely.ab.config.Experiment;
5+
import com.optimizely.ab.config.ProjectConfig;
6+
import com.optimizely.ab.config.Variation;
7+
8+
/**
9+
* Interface for persisting variation activations for an experiment
10+
*
11+
* Persistent bucketing is useful when the variation for a user should not ever change, even if
12+
* traffic allocation changes.
13+
*
14+
* Create an implementation of this interface and pass it to
15+
* {@link com.optimizely.ab.Optimizely.Builder#withPersistentBucketer(PersistentBucketer)} to inject
16+
* it into the returned {@link Optimizely} instance. The default Optimizely bucketer will
17+
* check the provided persistent bucketer for a persisted activation while bucketing. If the a variation
18+
* is returned that variation is used. Return null for {@link this#restoreActivation(ProjectConfig, String, Experiment)}
19+
* to tell the bucketer to bucket normally.
20+
*
21+
* Likewise, when the initial bucketing occurs the default Optimizely {@link Bucketer} will provide
22+
* the {@link ProjectConfig projectConfig}, userId, {@link Experiment}, and
23+
* {@link Variation} to {@link this#saveActivation(ProjectConfig, String, Experiment, Variation)}.
24+
* These parameters should provide enough data to persist the activation and restore an instance of
25+
* {@link Variation}
26+
*
27+
* If providing a custom {@link Bucketer} be sure to make it's bucketing methods respect the presence
28+
* of a {@link PersistentBucketer} if desired. {@link PersistentBucketer} instances should be composed
29+
* inside of {@link Bucketer} instances.
30+
*
31+
*
32+
*/
33+
public interface PersistentBucketer {
34+
35+
/**
36+
* Called after bucketing occurs, providing a chance to persist data about the activation
37+
* @param projectConfig an instance of {@link ProjectConfig}
38+
* @param userId a String representing a user ID
39+
* @param experiment an {@link Experiment} instance
40+
* @param variation a {@link Variation} id
41+
* @return true if saved otherwise false
42+
*/
43+
boolean saveActivation(ProjectConfig projectConfig, String userId, Experiment experiment, Variation variation);
44+
45+
/**
46+
* Called before bucketing occurs, providing a chance to restore a previously bucketed {@link Variation}
47+
* @param projectConfig an instance of {@link ProjectConfig}
48+
* @param userId a String representing a user ID
49+
* @param experiment an {@link Experiment} instance
50+
* @return an instance of {@link Variation}
51+
* The {@link ProjectConfig} instance can be used to look up the {@link Variation} instance
52+
* for the project via the userID string and the {@link Experiment} instance which has the experiment ID.
53+
*/
54+
Variation restoreActivation(ProjectConfig projectConfig, String userId, Experiment experiment);
55+
}

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.PersistentBucketer;
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 withPersistentBucketer() throws Exception {
92+
PersistentBucketer persistentBucketer = mock(PersistentBucketer.class);
93+
Optimizely optimizelyClient = Optimizely.builder(validConfigJson(), mockEventHandler)
94+
.withPersistentBucketer(persistentBucketer)
95+
.build();
96+
97+
assertThat(optimizelyClient.bucketer.getPersistentBucketer(), is(persistentBucketer));
98+
}
8799
}

core-api/src/test/java/com/optimizely/ab/bucketing/BucketerTest.java

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
import static org.hamcrest.MatcherAssert.assertThat;
4444
import static org.junit.Assert.assertNull;
4545
import static org.junit.Assert.assertTrue;
46+
import static org.mockito.Mockito.mock;
47+
import static org.mockito.Mockito.verify;
48+
import static org.mockito.Mockito.when;
4649

4750
/**
4851
* Tests for {@link Bucketer}.
@@ -440,6 +443,90 @@ public void bucketUserNotInOverlappingGroupExperiment() throws Exception {
440443
assertNull(algorithm.bucket(groupExperiment, "blah"));
441444
}
442445

446+
@Test public void bucketUserSaveActivationWithPersistentBucketer() throws Exception {
447+
final AtomicInteger bucketValue = new AtomicInteger();
448+
PersistentBucketer persistentBucketer = mock(PersistentBucketer.class);
449+
Bucketer algorithm = mockPersistentBucketAlgorith(bucketValue, persistentBucketer);
450+
bucketValue.set(3000);
451+
452+
ProjectConfig projectConfig = validProjectConfig();
453+
List<Experiment> groupExperiments = projectConfig.getGroups().get(0).getExperiments();
454+
Experiment groupExperiment = groupExperiments.get(0);
455+
final Variation variation = groupExperiment.getVariations().get(0);
456+
457+
when(persistentBucketer.saveActivation(projectConfig, "blah", groupExperiment, variation)).thenReturn(true);
458+
459+
assertThat(algorithm.bucket(groupExperiment, "blah"), is(variation));
460+
461+
logbackVerifier.expectMessage(Level.INFO,
462+
"Persisted variation \"e2_vtag1\" of experiment \"group_etag2\".");
463+
464+
verify(persistentBucketer).saveActivation(projectConfig, "blah", groupExperiment, variation);
465+
}
466+
467+
@Test public void bucketUserSaveActivationFailWithPersistentBucketer() throws Exception {
468+
final AtomicInteger bucketValue = new AtomicInteger();
469+
PersistentBucketer persistentBucketer = mock(PersistentBucketer.class);
470+
Bucketer algorithm = mockPersistentBucketAlgorith(bucketValue, persistentBucketer);
471+
bucketValue.set(3000);
472+
473+
ProjectConfig projectConfig = validProjectConfig();
474+
List<Experiment> groupExperiments = projectConfig.getGroups().get(0).getExperiments();
475+
Experiment groupExperiment = groupExperiments.get(0);
476+
final Variation variation = groupExperiment.getVariations().get(0);
477+
478+
when(persistentBucketer.saveActivation(projectConfig, "blah", groupExperiment, variation)).thenReturn(false);
479+
480+
assertThat(algorithm.bucket(groupExperiment, "blah"), is(variation));
481+
482+
logbackVerifier.expectMessage(Level.WARN,
483+
"Failed to persist variation \"e2_vtag1\" of experiment \"group_etag2\".");
484+
485+
verify(persistentBucketer).saveActivation(projectConfig, "blah", groupExperiment, variation);
486+
}
487+
488+
@Test public void bucketUserRestoreActivationWithPersistentBucketer() throws Exception {
489+
final AtomicInteger bucketValue = new AtomicInteger();
490+
PersistentBucketer persistentBucketer = mock(PersistentBucketer.class);
491+
Bucketer algorithm = mockPersistentBucketAlgorith(bucketValue, persistentBucketer);
492+
bucketValue.set(3000);
493+
494+
ProjectConfig projectConfig = validProjectConfig();
495+
List<Experiment> groupExperiments = projectConfig.getGroups().get(0).getExperiments();
496+
Experiment groupExperiment = groupExperiments.get(0);
497+
final Variation variation = groupExperiment.getVariations().get(0);
498+
499+
when(persistentBucketer.restoreActivation(projectConfig, "blah", groupExperiment)).thenReturn(variation);
500+
501+
assertThat(algorithm.bucket(groupExperiment, "blah"), is(variation));
502+
503+
logbackVerifier.expectMessage(Level.INFO,
504+
"Returning previously activated variation \"e2_vtag1\" from persistent bucketer.");
505+
506+
verify(persistentBucketer).restoreActivation(projectConfig, "blah", groupExperiment);
507+
}
508+
509+
@Test public void bucketUserRestoreActivationNullWithPersistentBucketer() throws Exception {
510+
final AtomicInteger bucketValue = new AtomicInteger();
511+
PersistentBucketer persistentBucketer = mock(PersistentBucketer.class);
512+
Bucketer algorithm = mockPersistentBucketAlgorith(bucketValue, persistentBucketer);
513+
bucketValue.set(3000);
514+
515+
ProjectConfig projectConfig = validProjectConfig();
516+
List<Experiment> groupExperiments = projectConfig.getGroups().get(0).getExperiments();
517+
Experiment groupExperiment = groupExperiments.get(0);
518+
final Variation variation = groupExperiment.getVariations().get(0);
519+
520+
when(persistentBucketer.restoreActivation(projectConfig, "blah", groupExperiment)).thenReturn(null);
521+
522+
assertThat(algorithm.bucket(groupExperiment, "blah"), is(variation));
523+
524+
logbackVerifier.expectMessage(Level.INFO,
525+
"No previously activated variation returned from persistent bucketer.");
526+
527+
verify(persistentBucketer).restoreActivation(projectConfig, "blah", groupExperiment);
528+
}
529+
443530
//======== Helper methods ========//
444531

445532
/**
@@ -455,4 +542,21 @@ int generateBucketValue(int hashCode) {
455542
}
456543
};
457544
}
545+
546+
/**
547+
* Sets up a mock algorithm that returns an expected bucket value.
548+
*
549+
* Includes a composed {@link PersistentBucketer} mock instance
550+
*
551+
* @param bucketValue the expected bucket value holder
552+
* @return the mock bucket algorithm
553+
*/
554+
private Bucketer mockPersistentBucketAlgorith(final AtomicInteger bucketValue, final PersistentBucketer persistentBucketer) {
555+
return new Bucketer(validProjectConfig(), persistentBucketer) {
556+
@Override
557+
int generateBucketValue(int hashCode) {
558+
return bucketValue.get();
559+
}
560+
};
561+
}
458562
}

0 commit comments

Comments
 (0)