Skip to content

Commit 24bdcd9

Browse files
author
Michael Ng
authored
refac(decision): Return nil variation instead of decision made property. (#79)
1 parent e82f7b6 commit 24bdcd9

21 files changed

+203
-431
lines changed

optimizely/client/client.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,12 @@ func (o *OptimizelyClient) IsFeatureEnabled(featureKey string, userContext entit
8181
return result, e1
8282
}
8383

84-
result = featureDecision.Variation.FeatureEnabled
84+
if featureDecision.Variation == nil {
85+
result = false
86+
} else {
87+
result = featureDecision.Variation.FeatureEnabled
88+
}
89+
8590
if result {
8691
logger.Info(fmt.Sprintf(`Feature "%s" is enabled for user "%s".`, featureKey, userID))
8792
} else {
@@ -90,7 +95,7 @@ func (o *OptimizelyClient) IsFeatureEnabled(featureKey string, userContext entit
9095

9196
if featureDecision.Source == decision.FeatureTest {
9297
// send impression event for feature tests
93-
impressionEvent := event.CreateImpressionUserEvent(projectConfig, featureDecision.Experiment, featureDecision.Variation, userContext)
98+
impressionEvent := event.CreateImpressionUserEvent(projectConfig, featureDecision.Experiment, *featureDecision.Variation, userContext)
9499
o.eventProcessor.ProcessEvent(impressionEvent)
95100
}
96101
return result, nil
@@ -243,7 +248,7 @@ func (o *OptimizelyClient) getFeatureVariable(featureKey, variableKey string, us
243248
}
244249

245250
featureDecision, e2 := o.decisionService.GetFeatureDecision(featureDecisionContext, userContext)
246-
if e2 == nil {
251+
if e2 == nil && featureDecision.Variation != nil {
247252
if v, ok := featureDecision.Variation.Variables[variable.ID]; ok && featureDecision.Variation.FeatureEnabled {
248253
featureValue = v.Value
249254
}

optimizely/client/client_test.go

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,7 @@ func TestIsFeatureEnabled(t *testing.T) {
219219

220220
expectedFeatureDecision := decision.FeatureDecision{
221221
Experiment: testExperiment,
222-
Variation: testVariation,
223-
Decision: decision.Decision{
224-
DecisionMade: true,
225-
},
222+
Variation: &testVariation,
226223
}
227224

228225
mockDecisionService := new(MockDecisionService)
@@ -354,17 +351,11 @@ func TestGetEnabledFeatures(t *testing.T) {
354351

355352
expectedFeatureDecisionEnabled := decision.FeatureDecision{
356353
Experiment: testExperimentEnabled,
357-
Variation: testVariationEnabled,
358-
Decision: decision.Decision{
359-
DecisionMade: true,
360-
},
354+
Variation: &testVariationEnabled,
361355
}
362356
expectedFeatureDecisionDisabled := decision.FeatureDecision{
363357
Experiment: testExperimentDisabled,
364-
Variation: testVariationDisabled,
365-
Decision: decision.Decision{
366-
DecisionMade: true,
367-
},
358+
Variation: &testVariationDisabled,
368359
}
369360

370361
mockDecisionService := new(MockDecisionService)
@@ -1434,10 +1425,7 @@ func TestGetFeatureVariableErrorCases(t *testing.T) {
14341425
func getTestFeatureDecision(experiment entities.Experiment, variation entities.Variation, decisionMade bool) decision.FeatureDecision {
14351426
return decision.FeatureDecision{
14361427
Experiment: experiment,
1437-
Variation: variation,
1438-
Decision: decision.Decision{
1439-
DecisionMade: decisionMade,
1440-
},
1428+
Variation: &variation,
14411429
}
14421430
}
14431431

optimizely/decision/bucketer/experiment_bucketer.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const maxTrafficValue = 10000
3535

3636
// ExperimentBucketer is used to bucket the user into a particular entity in the experiment's traffic alloc range
3737
type ExperimentBucketer interface {
38-
Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (entities.Variation, reasons.Reason, error)
38+
Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error)
3939
}
4040

4141
// MurmurhashBucketer buckets the user using the mmh3 algorightm
@@ -51,28 +51,28 @@ func NewMurmurhashBucketer(hashSeed uint32) *MurmurhashBucketer {
5151
}
5252

5353
// Bucket buckets the user into the given experiment
54-
func (b MurmurhashBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (entities.Variation, reasons.Reason, error) {
54+
func (b MurmurhashBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) (*entities.Variation, reasons.Reason, error) {
5555
if experiment.GroupID != "" && group.Policy == "random" {
5656
bucketKey := bucketingID + group.ID
5757
bucketedExperimentID := b.bucketToEntity(bucketKey, group.TrafficAllocation)
5858
if bucketedExperimentID == "" || bucketedExperimentID != experiment.ID {
59-
// User is not bucketed into an experiment in the exclusion group, return an empty variation
60-
return entities.Variation{}, reasons.NotInGroup, nil
59+
// User is not bucketed into an experiment in the exclusion group, return nil variation
60+
return nil, reasons.NotInGroup, nil
6161
}
6262
}
6363

6464
bucketKey := bucketingID + experiment.ID
6565
bucketedVariationID := b.bucketToEntity(bucketKey, experiment.TrafficAllocation)
6666
if bucketedVariationID == "" {
67-
// User is not bucketed into a variation in the experiment, return an empty variation
68-
return entities.Variation{}, reasons.NotBucketedIntoVariation, nil
67+
// User is not bucketed into a variation in the experiment, return nil variation
68+
return nil, reasons.NotBucketedIntoVariation, nil
6969
}
7070

7171
if variation, ok := experiment.Variations[bucketedVariationID]; ok {
72-
return variation, reasons.BucketedIntoVariation, nil
72+
return &variation, reasons.BucketedIntoVariation, nil
7373
}
7474

75-
return entities.Variation{ID: bucketedVariationID}, reasons.BucketedVariationNotFound, nil
75+
return nil, reasons.BucketedVariationNotFound, nil
7676
}
7777

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

optimizely/decision/bucketer/experiment_bucketer_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,10 @@ func TestBucketExclusionGroups(t *testing.T) {
109109
bucketer := NewMurmurhashBucketer(DefaultHashSeed)
110110
// ppid2 + 1886780722 (groupId) will generate bucket value of 2434 which maps to experiment 1
111111
bucketedVariation, reason, _ := bucketer.Bucket("ppid2", experiment1, exclusionGroup)
112-
assert.Equal(t, experiment1.Variations["22222"], bucketedVariation)
112+
assert.Equal(t, experiment1.Variations["22222"], *bucketedVariation)
113113
assert.Equal(t, reasons.BucketedIntoVariation, reason)
114114
// since the bucket value maps to experiment 1, the user will not be bucketed for experiment 2
115115
bucketedVariation, reason, _ = bucketer.Bucket("ppid2", experiment2, exclusionGroup)
116-
assert.Equal(t, entities.Variation{}, bucketedVariation)
116+
assert.Nil(t, bucketedVariation)
117117
assert.Equal(t, reasons.NotInGroup, reason)
118118
}

optimizely/decision/composite_experiment_service.go

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,38 +18,51 @@
1818
package decision
1919

2020
import (
21+
"github.com/optimizely/go-sdk/optimizely/decision/evaluator"
22+
"github.com/optimizely/go-sdk/optimizely/decision/reasons"
2123
"github.com/optimizely/go-sdk/optimizely/entities"
2224
)
2325

2426
// CompositeExperimentService bridges together the various experiment decision services that ship by default with the SDK
2527
type CompositeExperimentService struct {
26-
experimentDecisionServices []ExperimentService
28+
audienceTreeEvaluator evaluator.TreeEvaluator
29+
experimentServices []ExperimentService
2730
}
2831

2932
// NewCompositeExperimentService creates a new instance of the CompositeExperimentService
3033
func NewCompositeExperimentService() *CompositeExperimentService {
3134
// These decision services are applied in order:
32-
// 1. Targeting
33-
// 2. Bucketing
35+
// 1. Bucketing
3436
// @TODO(mng): Prepend forced variation and whitelisting services
35-
experimentDecisionServices := []ExperimentService{
36-
NewExperimentTargetingService(),
37-
NewExperimentBucketerService(),
38-
}
3937
return &CompositeExperimentService{
40-
experimentDecisionServices: experimentDecisionServices,
38+
audienceTreeEvaluator: evaluator.NewMixedTreeEvaluator(),
39+
experimentServices: []ExperimentService{
40+
NewExperimentBucketerService(),
41+
},
4142
}
4243
}
4344

4445
// GetDecision returns a decision for the given experiment and user context
4546
func (s CompositeExperimentService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext) (ExperimentDecision, error) {
46-
for _, experimentService := range s.experimentDecisionServices {
47-
decision, err := experimentService.GetDecision(decisionContext, userContext)
48-
if decision.DecisionMade {
47+
experimentDecision := ExperimentDecision{}
48+
experiment := decisionContext.Experiment
49+
50+
// Determine if user can be part of the experiment
51+
if experiment.AudienceConditionTree != nil {
52+
condTreeParams := entities.NewTreeParameters(&userContext, decisionContext.ProjectConfig.GetAudienceMap())
53+
evalResult := s.audienceTreeEvaluator.Evaluate(experiment.AudienceConditionTree, condTreeParams)
54+
if !evalResult {
55+
experimentDecision.Reason = reasons.FailedAudienceTargeting
56+
return experimentDecision, nil
57+
}
58+
}
59+
60+
// User passed targeting (or the experiment is untargeted), so run through the various decision services
61+
for _, experimentService := range s.experimentServices {
62+
if decision, err := experimentService.GetDecision(decisionContext, userContext); decision.Variation != nil {
4963
return decision, err
5064
}
5165
}
5266

53-
// zero-value for DecisionMade is false
54-
return ExperimentDecision{}, nil
67+
return experimentDecision, nil
5568
}

optimizely/decision/composite_experiment_service_test.go

Lines changed: 56 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -35,53 +35,35 @@ func TestCompositeExperimentServiceGetDecision(t *testing.T) {
3535
ID: "test_user_1",
3636
}
3737

38+
expectedVariation := testExp1111.Variations["2222"]
3839
expectedExperimentDecision := ExperimentDecision{
39-
Variation: testExp1111.Variations["2222"],
40-
Decision: Decision{
41-
DecisionMade: true,
42-
},
40+
Variation: &expectedVariation,
4341
}
4442
// test that we return out of the decision making and the next one doesn't get called
4543
mockExperimentDecisionService := new(MockExperimentDecisionService)
4644
mockExperimentDecisionService.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision, nil)
4745

4846
mockExperimentDecisionService2 := new(MockExperimentDecisionService)
4947
compositeExperimentService := &CompositeExperimentService{
50-
experimentDecisionServices: []ExperimentService{
51-
mockExperimentDecisionService,
52-
mockExperimentDecisionService2,
53-
},
48+
experimentServices: []ExperimentService{mockExperimentDecisionService, mockExperimentDecisionService2},
5449
}
5550
decision, err := compositeExperimentService.GetDecision(testDecisionContext, testUserContext)
56-
57-
assert.NoError(t, err)
58-
assert.Equal(t, expectedExperimentDecision, decision)
5951
mockExperimentDecisionService.AssertExpectations(t)
6052
mockExperimentDecisionService2.AssertNotCalled(t, "GetDecision")
6153

6254
// test that we move on to the next decision service if no decision is made
6355
mockExperimentDecisionService = new(MockExperimentDecisionService)
64-
expectedExperimentDecision = ExperimentDecision{
65-
Decision: Decision{
66-
DecisionMade: false,
67-
},
68-
}
56+
expectedExperimentDecision = ExperimentDecision{}
6957
mockExperimentDecisionService.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision, nil)
7058

7159
mockExperimentDecisionService2 = new(MockExperimentDecisionService)
7260
expectedExperimentDecision2 := ExperimentDecision{
73-
Variation: testExp1111.Variations["2222"],
74-
Decision: Decision{
75-
DecisionMade: true,
76-
},
61+
Variation: &expectedVariation,
7762
}
7863
mockExperimentDecisionService2.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision2, nil)
7964

8065
compositeExperimentService = &CompositeExperimentService{
81-
experimentDecisionServices: []ExperimentService{
82-
mockExperimentDecisionService,
83-
mockExperimentDecisionService2,
84-
},
66+
experimentServices: []ExperimentService{mockExperimentDecisionService, mockExperimentDecisionService2},
8567
}
8668
decision, err = compositeExperimentService.GetDecision(testDecisionContext, testUserContext)
8769

@@ -92,26 +74,15 @@ func TestCompositeExperimentServiceGetDecision(t *testing.T) {
9274

9375
// test when no decisions are made
9476
mockExperimentDecisionService = new(MockExperimentDecisionService)
95-
expectedExperimentDecision = ExperimentDecision{
96-
Decision: Decision{
97-
DecisionMade: false,
98-
},
99-
}
77+
expectedExperimentDecision = ExperimentDecision{}
10078
mockExperimentDecisionService.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision, nil)
10179

10280
mockExperimentDecisionService2 = new(MockExperimentDecisionService)
103-
expectedExperimentDecision2 = ExperimentDecision{
104-
Decision: Decision{
105-
DecisionMade: false,
106-
},
107-
}
81+
expectedExperimentDecision2 = ExperimentDecision{}
10882
mockExperimentDecisionService2.On("GetDecision", testDecisionContext, testUserContext).Return(expectedExperimentDecision2, nil)
10983

11084
compositeExperimentService = &CompositeExperimentService{
111-
experimentDecisionServices: []ExperimentService{
112-
mockExperimentDecisionService,
113-
mockExperimentDecisionService2,
114-
},
85+
experimentServices: []ExperimentService{mockExperimentDecisionService, mockExperimentDecisionService2},
11586
}
11687
decision, err = compositeExperimentService.GetDecision(testDecisionContext, testUserContext)
11788

@@ -120,3 +91,50 @@ func TestCompositeExperimentServiceGetDecision(t *testing.T) {
12091
mockExperimentDecisionService.AssertExpectations(t)
12192
mockExperimentDecisionService2.AssertExpectations(t)
12293
}
94+
95+
func TestCompositeExperimentServiceGetDecisionTargeting(t *testing.T) {
96+
testUserContext := entities.UserContext{
97+
ID: "test_user",
98+
}
99+
testAudienceMap := map[string]entities.Audience{
100+
"5555": testAudience5555,
101+
}
102+
mockProjectConfig := new(mockProjectConfig)
103+
mockProjectConfig.On("GetAudienceMap").Return(testAudienceMap)
104+
105+
testExperimentDecisionContext := ExperimentDecisionContext{
106+
Experiment: &testExp1112,
107+
ProjectConfig: mockProjectConfig,
108+
}
109+
testCondTreeParams := entities.NewTreeParameters(&testUserContext, testAudienceMap)
110+
111+
// Test user fails targeting
112+
mockAudienceTreeEvaluator := new(MockAudienceTreeEvaluator)
113+
mockAudienceTreeEvaluator.On("Evaluate", testExp1112.AudienceConditionTree, testCondTreeParams).Return(false)
114+
mockExperimentDecisionService := new(MockExperimentDecisionService)
115+
testCompositeExperimentService := &CompositeExperimentService{
116+
audienceTreeEvaluator: mockAudienceTreeEvaluator,
117+
experimentServices: []ExperimentService{mockExperimentDecisionService},
118+
}
119+
decision, _ := testCompositeExperimentService.GetDecision(testExperimentDecisionContext, testUserContext)
120+
assert.Nil(t, decision.Variation)
121+
mockAudienceTreeEvaluator.AssertExpectations(t)
122+
mockExperimentDecisionService.AssertNotCalled(t, "GetDecision")
123+
124+
// Test user passes targeting, moves on to children decision services
125+
expectedExperimentDecision := ExperimentDecision{
126+
Variation: &testExp1112Var2222,
127+
}
128+
mockAudienceTreeEvaluator = new(MockAudienceTreeEvaluator)
129+
mockAudienceTreeEvaluator.On("Evaluate", testExp1112.AudienceConditionTree, testCondTreeParams).Return(true)
130+
mockExperimentDecisionService = new(MockExperimentDecisionService)
131+
mockExperimentDecisionService.On("GetDecision", testExperimentDecisionContext, testUserContext).Return(expectedExperimentDecision, nil)
132+
testCompositeExperimentService = &CompositeExperimentService{
133+
audienceTreeEvaluator: mockAudienceTreeEvaluator,
134+
experimentServices: []ExperimentService{mockExperimentDecisionService},
135+
}
136+
decision, _ = testCompositeExperimentService.GetDecision(testExperimentDecisionContext, testUserContext)
137+
assert.Equal(t, decision, expectedExperimentDecision)
138+
mockAudienceTreeEvaluator.AssertExpectations(t)
139+
mockExperimentDecisionService.AssertExpectations(t)
140+
}

optimizely/decision/composite_feature_service.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,8 @@ func (f CompositeFeatureService) GetDecision(decisionContext FeatureDecisionCont
5454
}
5555

5656
experimentDecision, err := f.experimentDecisionService.GetDecision(experimentDecisionContext, userContext)
57-
// If we get an empty string Variation ID it means that the user is assigned no variation, hence we
58-
// move onto Rollout evaluation
59-
if experimentDecision.Variation.ID != "" {
57+
// Variation not nil means we got a decision and should return it
58+
if experimentDecision.Variation != nil {
6059
featureDecision := FeatureDecision{
6160
Experiment: experiment,
6261
Decision: experimentDecision.Decision,

optimizely/decision/composite_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func (s CompositeService) GetFeatureDecision(featureDecisionContext FeatureDecis
5555
func() {}() // cheat linters
5656
}
5757

58-
if featureDecision.DecisionMade {
58+
if featureDecision.Variation != nil {
5959
break
6060
}
6161
}

optimizely/decision/composite_service_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ func TestGetFeatureDecision(t *testing.T) {
3737

3838
expectedFeatureDecision := FeatureDecision{
3939
Experiment: testExp1111,
40-
Variation: testExp1111Var2222,
41-
Decision: Decision{DecisionMade: true},
40+
Variation: &testExp1111Var2222,
4241
}
4342

4443
testFeatureDecisionService := new(MockFeatureDecisionService)

optimizely/decision/entities.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,19 @@ const (
4747

4848
// Decision contains base information about a decision
4949
type Decision struct {
50-
DecisionMade bool
51-
Reason reasons.Reason
50+
Reason reasons.Reason
5251
}
5352

5453
// FeatureDecision contains the decision information about a feature
5554
type FeatureDecision struct {
5655
Decision
5756
Source Source
5857
Experiment entities.Experiment
59-
Variation entities.Variation
58+
Variation *entities.Variation
6059
}
6160

6261
// ExperimentDecision contains the decision information about an experiment
6362
type ExperimentDecision struct {
6463
Decision
65-
Variation entities.Variation
64+
Variation *entities.Variation
6665
}

0 commit comments

Comments
 (0)