Skip to content

Commit ee2ea26

Browse files
author
Michael Ng
authored
feat(decision): Add rollout service. (#43)
1 parent f5acd50 commit ee2ea26

13 files changed

+332
-32
lines changed

optimizely/decision/bucketer/experiment_bucketer.go

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

33
import (
4-
"fmt"
54
"math"
65

6+
"github.com/optimizely/go-sdk/optimizely/decision/reasons"
77
"github.com/optimizely/go-sdk/optimizely/entities"
88
"github.com/optimizely/go-sdk/optimizely/logging"
99
"github.com/twmb/murmur3"
@@ -18,7 +18,7 @@ const maxTrafficValue = 10000
1818

1919
// ExperimentBucketer is used to bucket the user into a particular entity in the experiment's traffic alloc range
2020
type ExperimentBucketer interface {
21-
Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) entities.Variation
21+
Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (entities.Variation, reasons.Reason, error)
2222
}
2323

2424
// MurmurhashBucketer buckets the user using the mmh3 algorightm
@@ -34,30 +34,28 @@ func NewMurmurhashBucketer(hashSeed uint32) *MurmurhashBucketer {
3434
}
3535

3636
// Bucket buckets the user into the given experiment
37-
func (b MurmurhashBucketer) 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, reasons.Reason, error) {
3838
if experiment.GroupID != "" && group.Policy == "random" {
3939
bucketKey := bucketingID + group.ID
4040
bucketedExperimentID := b.bucketToEntity(bucketKey, group.TrafficAllocation)
4141
if bucketedExperimentID == "" || bucketedExperimentID != experiment.ID {
4242
// 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))
44-
return entities.Variation{}
43+
return entities.Variation{}, reasons.NotInGroup, nil
4544
}
4645
}
4746

4847
bucketKey := bucketingID + experiment.ID
4948
bucketedVariationID := b.bucketToEntity(bucketKey, experiment.TrafficAllocation)
5049
if bucketedVariationID == "" {
5150
// 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))
53-
return entities.Variation{}
51+
return entities.Variation{}, reasons.NotBucketedIntoVariation, nil
5452
}
5553

5654
if variation, ok := experiment.Variations[bucketedVariationID]; ok {
57-
return variation
55+
return variation, reasons.BucketedIntoVariation, nil
5856
}
5957

60-
return entities.Variation{}
58+
return entities.Variation{}, reasons.BucketedVariationNotFound, nil
6159
}
6260

6361
func (b MurmurhashBucketer) bucketToEntity(bucketKey string, trafficAllocations []entities.Range) (entityID string) {

optimizely/decision/bucketer/experiment_bucketer_test.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"fmt"
55
"testing"
66

7+
"github.com/optimizely/go-sdk/optimizely/decision/reasons"
8+
79
"github.com/optimizely/go-sdk/optimizely/entities"
810
"github.com/stretchr/testify/assert"
911
)
@@ -69,7 +71,8 @@ func TestBucketToEntity(t *testing.T) {
6971

7072
func TestBucketExclusionGroups(t *testing.T) {
7173
experiment1 := entities.Experiment{
72-
ID: "1886780721",
74+
ID: "1886780721",
75+
Key: "experiment_1",
7376
Variations: map[string]entities.Variation{
7477
"22222": entities.Variation{ID: "22222", Key: "exp_1_var_1"},
7578
"22223": entities.Variation{ID: "22223", Key: "exp_1_var_2"},
@@ -81,7 +84,8 @@ func TestBucketExclusionGroups(t *testing.T) {
8184
GroupID: "1886780722",
8285
}
8386
experiment2 := entities.Experiment{
84-
ID: "1886780723",
87+
ID: "1886780723",
88+
Key: "experiment_2",
8589
Variations: map[string]entities.Variation{
8690
"22224": entities.Variation{ID: "22224", Key: "exp_2_var_1"},
8791
"22225": entities.Variation{ID: "22225", Key: "exp_2_var_2"},
@@ -104,7 +108,11 @@ func TestBucketExclusionGroups(t *testing.T) {
104108

105109
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
106110
// ppid2 + 1886780722 (groupId) will generate bucket value of 2434 which maps to experiment 1
107-
assert.Equal(t, experiment1.Variations["22222"], bucketer.Bucket("ppid2", experiment1, exclusionGroup))
111+
bucketedVariation, reason, _ := bucketer.Bucket("ppid2", experiment1, exclusionGroup)
112+
assert.Equal(t, experiment1.Variations["22222"], bucketedVariation)
113+
assert.Equal(t, reasons.BucketedIntoVariation, reason)
108114
// since the bucket value maps to experiment 1, the user will not be bucketed for experiment 2
109-
assert.Equal(t, entities.Variation{}, bucketer.Bucket("ppid2", experiment2, exclusionGroup))
115+
bucketedVariation, reason, _ = bucketer.Bucket("ppid2", experiment2, exclusionGroup)
116+
assert.Equal(t, entities.Variation{}, bucketedVariation)
117+
assert.Equal(t, reasons.NotInGroup, reason)
110118
}

optimizely/decision/composite_service_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import (
2626

2727
func TestGetFeatureDecision(t *testing.T) {
2828
mockProjectConfig := new(mockProjectConfig)
29-
mockProjectConfig.On("GetFeatureByKey", testFeat3333Key).Return(testFeat3333, nil)
3029
decisionContext := FeatureDecisionContext{
3130
Feature: &testFeat3333,
3231
ProjectConfig: mockProjectConfig,

optimizely/decision/entities.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package decision
1818

1919
import (
2020
"github.com/optimizely/go-sdk/optimizely"
21+
"github.com/optimizely/go-sdk/optimizely/decision/reasons"
2122
"github.com/optimizely/go-sdk/optimizely/entities"
2223
)
2324

@@ -36,6 +37,7 @@ type FeatureDecisionContext struct {
3637
// Decision contains base information about a decision
3738
type Decision struct {
3839
DecisionMade bool
40+
Reason reasons.Reason
3941
}
4042

4143
// FeatureDecision contains the decision information about a feature

optimizely/decision/experiment_bucketer_service.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,14 @@ func (s ExperimentBucketerService) GetDecision(decisionContext ExperimentDecisio
5252
// bucket user into a variation
5353
bucketingID, err := userContext.GetBucketingID()
5454
if err != nil {
55-
bLogger.Warning(err.Error())
56-
} else {
57-
bLogger.Debug(fmt.Sprintf(`Using bucketing ID: "%s"`, bucketingID))
55+
return experimentDecision, fmt.Errorf(`Error computing bucketing ID for experiment "%s": "%s"`, experiment.Key, err.Error())
5856
}
59-
variation := s.bucketer.Bucket(bucketingID, *experiment, group)
57+
58+
bLogger.Debug(fmt.Sprintf(`Using bucketing ID: "%s"`, bucketingID))
59+
// @TODO: handle error from bucketer
60+
variation, reason, _ := s.bucketer.Bucket(bucketingID, *experiment, group)
6061
experimentDecision.DecisionMade = true
62+
experimentDecision.Reason = reason
6163
experimentDecision.Variation = variation
6264
return experimentDecision, nil
6365
}

optimizely/decision/experiment_bucketer_service_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package decision
1919
import (
2020
"testing"
2121

22+
"github.com/optimizely/go-sdk/optimizely/decision/reasons"
23+
2224
"github.com/optimizely/go-sdk/optimizely/entities"
2325
"github.com/stretchr/testify/assert"
2426
"github.com/stretchr/testify/mock"
@@ -28,9 +30,9 @@ type MockBucketer struct {
2830
mock.Mock
2931
}
3032

31-
func (m *MockBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) entities.Variation {
33+
func (m *MockBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (entities.Variation, reasons.Reason, error) {
3234
args := m.Called(bucketingID, experiment, group)
33-
return args.Get(0).(entities.Variation)
35+
return args.Get(0).(entities.Variation), args.Get(1).(reasons.Reason), args.Error(2)
3436
}
3537

3638
func TestExperimentBucketerGetDecision(t *testing.T) {
@@ -48,10 +50,11 @@ func TestExperimentBucketerGetDecision(t *testing.T) {
4850
Variation: testExp1111Var2222,
4951
Decision: Decision{
5052
DecisionMade: true,
53+
Reason: reasons.BucketedIntoVariation,
5154
},
5255
}
5356
mockBucketer := new(MockBucketer)
54-
mockBucketer.On("Bucket", testUserContext.ID, testExp1111, entities.Group{}).Return(testExp1111Var2222, nil)
57+
mockBucketer.On("Bucket", testUserContext.ID, testExp1111, entities.Group{}).Return(testExp1111Var2222, reasons.BucketedIntoVariation, nil)
5558

5659
experimentBucketerService := ExperimentBucketerService{
5760
bucketer: mockBucketer,

optimizely/decision/experiment_targeting_service.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package decision
1818

1919
import (
20+
"github.com/optimizely/go-sdk/optimizely/decision/reasons"
21+
2022
"github.com/optimizely/go-sdk/optimizely/decision/evaluator"
2123
"github.com/optimizely/go-sdk/optimizely/entities"
2224
)
@@ -46,6 +48,7 @@ func (s ExperimentTargetingService) GetDecision(decisionContext ExperimentDecisi
4648
if !evalResult {
4749
// user not targeted for experiment, return an empty variation
4850
experimentDecision.DecisionMade = true
51+
experimentDecision.Reason = reasons.DoesNotQualify
4952
}
5053
return experimentDecision, nil
5154
}
@@ -58,6 +61,7 @@ func (s ExperimentTargetingService) GetDecision(decisionContext ExperimentDecisi
5861
if evalResult == false {
5962
// user not targeted for experiment, return an empty variation
6063
experimentDecision.DecisionMade = true
64+
experimentDecision.Reason = reasons.DoesNotQualify
6165
return experimentDecision, nil
6266
}
6367
}

optimizely/decision/experiment_targeting_service_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package decision
1919
import (
2020
"testing"
2121

22+
"github.com/optimizely/go-sdk/optimizely/decision/reasons"
23+
2224
"github.com/optimizely/go-sdk/optimizely/decision/evaluator"
2325
"github.com/optimizely/go-sdk/optimizely/entities"
2426
"github.com/stretchr/testify/assert"
@@ -74,6 +76,7 @@ func TestExperimentTargetingGetDecisionNoAudienceCondTree(t *testing.T) {
7476
expectedExperimentDecision := ExperimentDecision{
7577
Decision: Decision{
7678
DecisionMade: true,
79+
Reason: reasons.DoesNotQualify,
7780
},
7881
}
7982

@@ -155,8 +158,8 @@ func TestExperimentTargetingGetDecisionWithAudienceCondTree(t *testing.T) {
155158
expectedExperimentDecision := ExperimentDecision{
156159
Decision: Decision{
157160
DecisionMade: true,
161+
Reason: reasons.DoesNotQualify,
158162
},
159-
Variation: entities.Variation{},
160163
}
161164

162165
audienceEvaluator := evaluator.NewTypedAudienceEvaluator()

optimizely/decision/helpers_test.go

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,30 +64,50 @@ func (m *MockFeatureDecisionService) GetDecision(decisionContext FeatureDecision
6464
return args.Get(0).(FeatureDecision), args.Error(1)
6565
}
6666

67+
// Single variation experiment
6768
const testExp1111Key = "test_experiment_1111"
68-
const testFeat3333Key = "my_test_feature_3333"
69-
70-
var testExp1111Var2222 = entities.Variation{
71-
ID: "2222",
72-
Key: "2222",
73-
}
7469

70+
var testExp1111Var2222 = entities.Variation{ID: "2222", Key: "2222"}
7571
var testExp1111 = entities.Experiment{
7672
ID: "1111",
7773
Key: testExp1111Key,
7874
Variations: map[string]entities.Variation{
7975
"2222": testExp1111Var2222,
8076
},
8177
TrafficAllocation: []entities.Range{
82-
entities.Range{
83-
EntityID: "2222",
84-
EndOfRange: 10000,
85-
},
78+
entities.Range{EntityID: "2222", EndOfRange: 10000},
8679
},
8780
}
8881

82+
// Simple feature test
83+
const testFeat3333Key = "my_test_feature_3333"
84+
8985
var testFeat3333 = entities.Feature{
9086
ID: "3333",
9187
Key: testFeat3333Key,
9288
FeatureExperiments: []entities.Experiment{testExp1111},
9389
}
90+
91+
// Feature rollout
92+
var testExp1112Var2222 = entities.Variation{ID: "2222", Key: "2222"}
93+
var testExp1112 = entities.Experiment{
94+
ID: "1112",
95+
Key: testExp1111Key,
96+
Variations: map[string]entities.Variation{
97+
"2222": testExp1111Var2222,
98+
},
99+
TrafficAllocation: []entities.Range{
100+
entities.Range{EntityID: "2222", EndOfRange: 10000},
101+
},
102+
}
103+
104+
const testFeatRollout3334Key = "test_feature_rollout_3334_key"
105+
106+
var testFeatRollout3334 = entities.Feature{
107+
ID: "3334",
108+
Key: testFeatRollout3334Key,
109+
Rollout: entities.Rollout{
110+
ID: "4444",
111+
Experiments: []entities.Experiment{testExp1112},
112+
},
113+
}

optimizely/decision/reasons/reason.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 reasons
18+
19+
// Reason is the reason for which a decision was made
20+
type Reason int
21+
22+
const (
23+
_ Reason = iota
24+
// BucketedVariationNotFound - the bucketed variation ID is not in the config
25+
BucketedVariationNotFound
26+
// BucketedIntoVariation - the user is bucketed into a variation for the given experiment
27+
BucketedIntoVariation
28+
// DoesNotMeetRolloutTargeting - the user does not meet the rollout targeting rules
29+
DoesNotMeetRolloutTargeting
30+
// DoesNotQualify - the user did not qualify for the experiment
31+
DoesNotQualify
32+
// NoRolloutForFeature - there is no rollout for the given feature
33+
NoRolloutForFeature
34+
// RolloutHasNoExperiments - the rollout has no assigned experiments
35+
RolloutHasNoExperiments
36+
// NotBucketedIntoVariation - the user is not bucketed into a variation for the given experiment
37+
NotBucketedIntoVariation
38+
// NotInGroup - the user is not bucketed into the mutex group
39+
NotInGroup
40+
)

0 commit comments

Comments
 (0)