Skip to content

Commit 1de7815

Browse files
author
Michael Ng
authored
feat(decision): Adds composite experiment bucketer that targets and buckets (#35)
1 parent 20e7011 commit 1de7815

12 files changed

+512
-27
lines changed

optimizely/decision/bucketer/experiment_bucketer.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,46 @@
11
package bucketer
22

33
import (
4+
"fmt"
45
"math"
56

67
"github.com/optimizely/go-sdk/optimizely/entities"
8+
"github.com/optimizely/go-sdk/optimizely/logging"
79
"github.com/twmb/murmur3"
810
)
911

12+
var logger = logging.GetLogger("ExperimentBucketer")
1013
var maxHashValue = float32(math.Pow(2, 32))
1114

1215
// DefaultHashSeed is the hash seed to use for murmurhash
1316
const DefaultHashSeed = 1
1417
const maxTrafficValue = 10000
1518

16-
// ExperimentBucketer buckets the user
17-
type ExperimentBucketer struct {
19+
// ExperimentBucketer is used to bucket the user into a particular entity in the experiment's traffic alloc range
20+
type ExperimentBucketer interface {
21+
Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) entities.Variation
22+
}
23+
24+
// MurmurhashBucketer buckets the user using the mmh3 algorightm
25+
type MurmurhashBucketer struct {
1826
hashSeed uint32
1927
}
2028

21-
// NewExperimentBucketer returns a new instance of the experiment bucketer
22-
func NewExperimentBucketer(hashSeed uint32) *ExperimentBucketer {
23-
return &ExperimentBucketer{
29+
// NewMurmurhashBucketer returns a new instance of the experiment bucketer
30+
func NewMurmurhashBucketer(hashSeed uint32) *MurmurhashBucketer {
31+
return &MurmurhashBucketer{
2432
hashSeed: hashSeed,
2533
}
2634
}
2735

2836
// Bucket buckets the user into the given experiment
29-
func (b *ExperimentBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) entities.Variation {
37+
func (b MurmurhashBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) entities.Variation {
3038
if experiment.GroupID != "" && group.Policy == "random" {
3139
bucketKey := bucketingID + group.ID
3240
bucketedExperimentID := b.bucketToEntity(bucketKey, group.TrafficAllocation)
3341
if bucketedExperimentID == "" || bucketedExperimentID != experiment.ID {
3442
// User is not bucketed into an experiment in the exclusion group, return an empty variation
43+
logger.Info(fmt.Sprintf(`User "%s" is not in any experiment of group "%s"`, bucketingID, group.ID))
3544
return entities.Variation{}
3645
}
3746
}
@@ -40,6 +49,7 @@ func (b *ExperimentBucketer) Bucket(bucketingID string, experiment entities.Expe
4049
bucketedVariationID := b.bucketToEntity(bucketKey, experiment.TrafficAllocation)
4150
if bucketedVariationID == "" {
4251
// User is not bucketed into a variation in the experiment, return an empty variation
52+
logger.Info(fmt.Sprintf(`User "%s" is not in any variation of experiment "%s"`, bucketingID, experiment.ID))
4353
return entities.Variation{}
4454
}
4555

@@ -50,9 +60,9 @@ func (b *ExperimentBucketer) Bucket(bucketingID string, experiment entities.Expe
5060
return entities.Variation{}
5161
}
5262

53-
func (b *ExperimentBucketer) bucketToEntity(bucketKey string, trafficAllocations []entities.Range) (entityID string) {
54-
// TODO(mng): return log message re: bucket value
63+
func (b MurmurhashBucketer) bucketToEntity(bucketKey string, trafficAllocations []entities.Range) (entityID string) {
5564
bucketValue := b.generateBucketValue(bucketKey)
65+
5666
var currentEndOfRange int
5767
for _, trafficAllocationRange := range trafficAllocations {
5868
currentEndOfRange = trafficAllocationRange.EndOfRange
@@ -64,7 +74,7 @@ func (b *ExperimentBucketer) bucketToEntity(bucketKey string, trafficAllocations
6474
return ""
6575
}
6676

67-
func (b *ExperimentBucketer) generateBucketValue(bucketingKey string) int {
77+
func (b MurmurhashBucketer) generateBucketValue(bucketingKey string) int {
6878
hasher := murmur3.SeedNew32(b.hashSeed)
6979
hasher.Write([]byte(bucketingKey))
7080
hashCode := hasher.Sum32()

optimizely/decision/bucketer/experiment_bucketer_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
)
1010

1111
func TestGenerateBucketValue(t *testing.T) {
12-
bucketer := NewExperimentBucketer(DefaultHashSeed)
12+
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
1313

1414
// copied from unit tests in the other SDKs
1515
experimentID := "1886780721"
@@ -26,7 +26,7 @@ func TestGenerateBucketValue(t *testing.T) {
2626
}
2727

2828
func TestBucketToEntity(t *testing.T) {
29-
bucketer := NewExperimentBucketer(DefaultHashSeed)
29+
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
3030

3131
experimentID := "1886780721"
3232
experimentID2 := "1886780722"
@@ -102,7 +102,7 @@ func TestBucketExclusionGroups(t *testing.T) {
102102
},
103103
}
104104

105-
bucketer := NewExperimentBucketer(DefaultHashSeed)
105+
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
106106
// ppid2 + 1886780722 (groupId) will generate bucket value of 2434 which maps to experiment 1
107107
assert.Equal(t, experiment1.Variations["22222"], bucketer.Bucket("ppid2", experiment1, exclusionGroup))
108108
// since the bucket value maps to experiment 1, the user will not be bucketed for experiment 2
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/****************************************************************************
2+
* Copyright 2019, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
package decision
18+
19+
import (
20+
"github.com/optimizely/go-sdk/optimizely/entities"
21+
)
22+
23+
// CompositeExperimentService bridges together the various experiment decision services that ship by default with the SDK
24+
type CompositeExperimentService struct {
25+
experimentDecisionServices []ExperimentDecisionService
26+
}
27+
28+
// NewCompositeExperimentService creates a new instance of the CompositeExperimentService
29+
func NewCompositeExperimentService() *CompositeExperimentService {
30+
// These decision services are applied in order:
31+
// 1. Targeting
32+
// 2. Bucketing
33+
// @TODO(mng): Prepend forced variation and whitelisting services
34+
experimentDecisionServices := []ExperimentDecisionService{
35+
NewExperimentTargetingService(),
36+
NewExperimentBucketerService(),
37+
}
38+
return &CompositeExperimentService{
39+
experimentDecisionServices: experimentDecisionServices,
40+
}
41+
}
42+
43+
// GetDecision returns a decision for the given experiment and user context
44+
func (s CompositeExperimentService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext) (ExperimentDecision, error) {
45+
for _, experimentService := range s.experimentDecisionServices {
46+
decision, err := experimentService.GetDecision(decisionContext, userContext)
47+
if decision.DecisionMade == true {
48+
return decision, err
49+
}
50+
}
51+
52+
experimentDecision := ExperimentDecision{
53+
Decision: Decision{
54+
DecisionMade: false,
55+
},
56+
}
57+
return experimentDecision, nil
58+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/****************************************************************************
2+
* Copyright 2019, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
package decision
18+
19+
import (
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
24+
"github.com/optimizely/go-sdk/optimizely/entities"
25+
"github.com/stretchr/testify/mock"
26+
)
27+
28+
type MockExperimentDecisionService struct {
29+
mock.Mock
30+
}
31+
32+
func (m *MockExperimentDecisionService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext) (ExperimentDecision, error) {
33+
args := m.Called(decisionContext, userContext)
34+
return args.Get(0).(ExperimentDecision), args.Error(1)
35+
}
36+
37+
func TestCompositeExperimentServiceGetDecision(t *testing.T) {
38+
testDecisionContext := ExperimentDecisionContext{
39+
Experiment: entities.Experiment{
40+
ID: "111111",
41+
Variations: map[string]entities.Variation{
42+
"22222": entities.Variation{
43+
ID: "22222",
44+
Key: "22222",
45+
},
46+
},
47+
},
48+
}
49+
50+
testUserContext := entities.UserContext{
51+
ID: "test_user_1",
52+
}
53+
54+
expectedExperimentDecision := ExperimentDecision{
55+
Variation: testDecisionContext.Experiment.Variations["22222"],
56+
Decision: Decision{
57+
DecisionMade: true,
58+
},
59+
}
60+
// test that we return out of the decision making and the next one doesn't get called
61+
mockExperimentDecisionService := new(MockExperimentDecisionService)
62+
mockExperimentDecisionService.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision, nil)
63+
64+
mockExperimentDecisionService2 := new(MockExperimentDecisionService)
65+
compositeExperimentService := &CompositeExperimentService{
66+
experimentDecisionServices: []ExperimentDecisionService{
67+
mockExperimentDecisionService,
68+
mockExperimentDecisionService2,
69+
},
70+
}
71+
decision, err := compositeExperimentService.GetDecision(testDecisionContext, testUserContext)
72+
73+
assert.NoError(t, err)
74+
assert.Equal(t, expectedExperimentDecision, decision)
75+
mockExperimentDecisionService.AssertExpectations(t)
76+
mockExperimentDecisionService2.AssertNotCalled(t, "GetDecision")
77+
78+
// test that we move on to the next decision service if no decision is made
79+
mockExperimentDecisionService = new(MockExperimentDecisionService)
80+
expectedExperimentDecision = ExperimentDecision{
81+
Decision: Decision{
82+
DecisionMade: false,
83+
},
84+
}
85+
mockExperimentDecisionService.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision, nil)
86+
87+
mockExperimentDecisionService2 = new(MockExperimentDecisionService)
88+
expectedExperimentDecision2 := ExperimentDecision{
89+
Variation: testDecisionContext.Experiment.Variations["22222"],
90+
Decision: Decision{
91+
DecisionMade: true,
92+
},
93+
}
94+
mockExperimentDecisionService2.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision2, nil)
95+
96+
compositeExperimentService = &CompositeExperimentService{
97+
experimentDecisionServices: []ExperimentDecisionService{
98+
mockExperimentDecisionService,
99+
mockExperimentDecisionService2,
100+
},
101+
}
102+
decision, err = compositeExperimentService.GetDecision(testDecisionContext, testUserContext)
103+
104+
assert.NoError(t, err)
105+
assert.Equal(t, expectedExperimentDecision2, decision)
106+
mockExperimentDecisionService.AssertExpectations(t)
107+
mockExperimentDecisionService2.AssertExpectations(t)
108+
109+
// test when no decisions are made
110+
mockExperimentDecisionService = new(MockExperimentDecisionService)
111+
expectedExperimentDecision = ExperimentDecision{
112+
Decision: Decision{
113+
DecisionMade: false,
114+
},
115+
}
116+
mockExperimentDecisionService.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision, nil)
117+
118+
mockExperimentDecisionService2 = new(MockExperimentDecisionService)
119+
expectedExperimentDecision2 = ExperimentDecision{
120+
Decision: Decision{
121+
DecisionMade: false,
122+
},
123+
}
124+
mockExperimentDecisionService2.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision2, nil)
125+
126+
compositeExperimentService = &CompositeExperimentService{
127+
experimentDecisionServices: []ExperimentDecisionService{
128+
mockExperimentDecisionService,
129+
mockExperimentDecisionService2,
130+
},
131+
}
132+
decision, err = compositeExperimentService.GetDecision(testDecisionContext, testUserContext)
133+
134+
assert.NoError(t, err)
135+
assert.Equal(t, expectedExperimentDecision2, decision)
136+
mockExperimentDecisionService.AssertExpectations(t)
137+
mockExperimentDecisionService2.AssertExpectations(t)
138+
}

optimizely/decision/composite_feature_service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ type CompositeFeatureService struct {
2929
// NewCompositeFeatureService returns a new instance of the CompositeFeatureService
3030
func NewCompositeFeatureService(experimentDecisionService ExperimentDecisionService) *CompositeFeatureService {
3131
if experimentDecisionService == nil {
32-
experimentDecisionService = NewExperimentBucketerService()
32+
experimentDecisionService = NewCompositeExperimentService()
3333
}
3434
return &CompositeFeatureService{
3535
experimentDecisionService: experimentDecisionService,
3636
}
3737
}
3838

3939
// GetDecision returns a decision for the given feature and user context
40-
func (featureService *CompositeFeatureService) GetDecision(decisionContext FeatureDecisionContext, userContext entities.UserContext) (FeatureDecision, error) {
40+
func (featureService CompositeFeatureService) GetDecision(decisionContext FeatureDecisionContext, userContext entities.UserContext) (FeatureDecision, error) {
4141
featureEnabled := false
4242
feature := decisionContext.Feature
4343

optimizely/decision/composite_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type CompositeService struct {
2828

2929
// NewCompositeService returns a new instance of the DefeaultDecisionEngine
3030
func NewCompositeService() *CompositeService {
31-
experimentDecisionService := NewExperimentBucketerService()
31+
experimentDecisionService := NewCompositeExperimentService()
3232
featureDecisionService := NewCompositeFeatureService(experimentDecisionService)
3333
return &CompositeService{
3434
experimentDecisionServices: []ExperimentDecisionService{experimentDecisionService},

optimizely/decision/entities.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ import "github.com/optimizely/go-sdk/optimizely/entities"
2020

2121
// ExperimentDecisionContext contains the information needed to be able to make a decision for a given experiment
2222
type ExperimentDecisionContext struct {
23-
Experiment entities.Experiment
24-
Group entities.Group
23+
AudienceMap map[string]entities.Audience
24+
Experiment entities.Experiment
25+
Group entities.Group
2526
}
2627

2728
// FeatureDecisionContext contains the information needed to be able to make a decision for a given feature
@@ -44,5 +45,5 @@ type FeatureDecision struct {
4445
// ExperimentDecision contains the decision information about an experiment
4546
type ExperimentDecision struct {
4647
Decision
47-
Variation *entities.Variation
48+
Variation entities.Variation
4849
}

0 commit comments

Comments
 (0)