Skip to content

Commit 168b57c

Browse files
author
Michael Ng
authored
feat(decision): User profile service (#163)
1 parent cbb760f commit 168b57c

File tree

4 files changed

+265
-0
lines changed

4 files changed

+265
-0
lines changed

pkg/decision/entities.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,23 @@ type ExperimentDecision struct {
6363
Decision
6464
Variation *entities.Variation
6565
}
66+
67+
// UserDecisionKey is used to access the saved decisions in a user profile
68+
type UserDecisionKey struct {
69+
ExperimentID string
70+
Field string
71+
}
72+
73+
// NewUserDecisionKey returns a new UserDecisionKey with the given experiment ID
74+
func NewUserDecisionKey(experimentID string) UserDecisionKey {
75+
return UserDecisionKey{
76+
ExperimentID: experimentID,
77+
Field: "variation_id",
78+
}
79+
}
80+
81+
// UserProfile represents a saved user profile
82+
type UserProfile struct {
83+
ID string
84+
ExperimentBucketMap map[UserDecisionKey]string
85+
}

pkg/decision/interface.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,9 @@ type ExperimentService interface {
3939
type FeatureService interface {
4040
GetDecision(decisionContext FeatureDecisionContext, userContext entities.UserContext) (FeatureDecision, error)
4141
}
42+
43+
// UserProfileService is used to save and retrieve past bucketing decisions for users
44+
type UserProfileService interface {
45+
Lookup(string) UserProfile
46+
Save(UserProfile)
47+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
package decision
19+
20+
import (
21+
"fmt"
22+
23+
"github.com/optimizely/go-sdk/pkg/entities"
24+
"github.com/optimizely/go-sdk/pkg/logging"
25+
)
26+
27+
var pesLogger = logging.GetLogger("pkg/decision/persisting_experiment_service")
28+
29+
// PersistingExperimentService attempts to retrieve a saved decision from the user profile service
30+
// for the user before having the ExperimentBucketerService compute it.
31+
// If computed, the decision is saved back to the user profile service if provided.
32+
type PersistingExperimentService struct {
33+
experimentBucketedService ExperimentService
34+
userProfileService UserProfileService
35+
}
36+
37+
// NewPersistingExperimentService returns a new instance of the PersistingExperimentService
38+
func NewPersistingExperimentService(experimentBucketerService ExperimentService, userProfileService UserProfileService) *PersistingExperimentService {
39+
persistingExperimentService := &PersistingExperimentService{
40+
experimentBucketedService: experimentBucketerService,
41+
userProfileService: userProfileService,
42+
}
43+
44+
return persistingExperimentService
45+
}
46+
47+
// GetDecision returns the decision with the variation the user is bucketed into
48+
func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext) (experimentDecision ExperimentDecision, err error) {
49+
if p.userProfileService == nil {
50+
return p.experimentBucketedService.GetDecision(decisionContext, userContext)
51+
}
52+
53+
var userProfile UserProfile
54+
// check to see if there is a saved decision for the user
55+
experimentDecision, userProfile = p.getSavedDecision(decisionContext, userContext)
56+
if experimentDecision.Variation != nil {
57+
return experimentDecision, nil
58+
}
59+
60+
experimentDecision, err = p.experimentBucketedService.GetDecision(decisionContext, userContext)
61+
if experimentDecision.Variation != nil {
62+
// save decision if a user profile service is provided
63+
p.saveDecision(userProfile, decisionContext.Experiment, experimentDecision)
64+
}
65+
66+
return experimentDecision, err
67+
}
68+
69+
func (p PersistingExperimentService) getSavedDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext) (ExperimentDecision, UserProfile) {
70+
experimentDecision := ExperimentDecision{}
71+
userProfile := p.userProfileService.Lookup(userContext.ID)
72+
73+
// look up experiment decision from user profile
74+
decisionKey := NewUserDecisionKey(decisionContext.Experiment.ID)
75+
if userProfile.ExperimentBucketMap == nil {
76+
return experimentDecision, userProfile
77+
}
78+
79+
if savedVariationID, ok := userProfile.ExperimentBucketMap[decisionKey]; ok {
80+
if variation, ok := decisionContext.Experiment.Variations[savedVariationID]; ok {
81+
experimentDecision.Variation = &variation
82+
pesLogger.Debug(fmt.Sprintf(`User "%s" was previously bucketed into variation "%s" of experiment "%s".`, userContext.ID, variation.Key, decisionContext.Experiment.Key))
83+
} else {
84+
pesLogger.Warning(fmt.Sprintf(`User "%s" was previously bucketed into variation with ID "%s" for experiment "%s", but no matching variation was found.`, userContext.ID, savedVariationID, decisionContext.Experiment.Key))
85+
}
86+
}
87+
88+
return experimentDecision, userProfile
89+
}
90+
91+
func (p PersistingExperimentService) saveDecision(userProfile UserProfile, experiment *entities.Experiment, decision ExperimentDecision) {
92+
if p.userProfileService != nil {
93+
decisionKey := NewUserDecisionKey(experiment.ID)
94+
if userProfile.ExperimentBucketMap == nil {
95+
userProfile.ExperimentBucketMap = map[UserDecisionKey]string{}
96+
}
97+
userProfile.ExperimentBucketMap[decisionKey] = decision.Variation.ID
98+
p.userProfileService.Save(userProfile)
99+
}
100+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
package decision
19+
20+
import (
21+
"testing"
22+
23+
"github.com/optimizely/go-sdk/pkg/entities"
24+
"github.com/stretchr/testify/mock"
25+
"github.com/stretchr/testify/suite"
26+
)
27+
28+
type MockUserProfileService struct {
29+
UserProfileService
30+
mock.Mock
31+
}
32+
33+
func (m *MockUserProfileService) Lookup(userID string) UserProfile {
34+
args := m.Called(userID)
35+
return args.Get(0).(UserProfile)
36+
}
37+
38+
func (m *MockUserProfileService) Save(userProfile UserProfile) {
39+
m.Called(userProfile)
40+
}
41+
42+
var testUserContext entities.UserContext = entities.UserContext{
43+
ID: "test_user_1",
44+
}
45+
46+
type PersistingExperimentServiceTestSuite struct {
47+
suite.Suite
48+
mockProjectConfig *mockProjectConfig
49+
mockExperimentService *MockExperimentDecisionService
50+
mockUserProfileService *MockUserProfileService
51+
testComputedDecision ExperimentDecision
52+
testDecisionContext ExperimentDecisionContext
53+
}
54+
55+
func (s *PersistingExperimentServiceTestSuite) SetupTest() {
56+
s.mockProjectConfig = new(mockProjectConfig)
57+
s.mockExperimentService = new(MockExperimentDecisionService)
58+
s.mockUserProfileService = new(MockUserProfileService)
59+
s.testDecisionContext = ExperimentDecisionContext{
60+
Experiment: &testExp1113,
61+
ProjectConfig: s.mockProjectConfig,
62+
}
63+
64+
computedVariation := testExp1113.Variations["2223"]
65+
s.testComputedDecision = ExperimentDecision{
66+
Variation: &computedVariation,
67+
}
68+
s.mockExperimentService.On("GetDecision", s.testDecisionContext, testUserContext).Return(s.testComputedDecision, nil)
69+
}
70+
71+
func (s *PersistingExperimentServiceTestSuite) TestNilUserProfileService() {
72+
persistingExperimentService := NewPersistingExperimentService(s.mockExperimentService, nil)
73+
decision, err := persistingExperimentService.GetDecision(s.testDecisionContext, testUserContext)
74+
s.Equal(s.testComputedDecision, decision)
75+
s.NoError(err)
76+
s.mockExperimentService.AssertExpectations(s.T())
77+
}
78+
79+
func (s *PersistingExperimentServiceTestSuite) TestSavedVariationFound() {
80+
decisionKey := NewUserDecisionKey(s.testDecisionContext.Experiment.ID)
81+
savedUserProfile := UserProfile{
82+
ID: testUserContext.ID,
83+
ExperimentBucketMap: map[UserDecisionKey]string{decisionKey: testExp1113Var2224.ID},
84+
}
85+
s.mockUserProfileService.On("Lookup", testUserContext.ID).Return(savedUserProfile)
86+
s.mockUserProfileService.On("Save", mock.Anything)
87+
88+
persistingExperimentService := NewPersistingExperimentService(s.mockExperimentService, s.mockUserProfileService)
89+
decision, err := persistingExperimentService.GetDecision(s.testDecisionContext, testUserContext)
90+
savedDecision := ExperimentDecision{
91+
Variation: &testExp1113Var2224,
92+
}
93+
s.Equal(savedDecision, decision)
94+
s.NoError(err)
95+
s.mockExperimentService.AssertNotCalled(s.T(), "GetDecision", s.testDecisionContext, testUserContext)
96+
s.mockUserProfileService.AssertNotCalled(s.T(), "Save", mock.Anything)
97+
}
98+
99+
func (s *PersistingExperimentServiceTestSuite) TestNoSavedVariation() {
100+
s.mockUserProfileService.On("Lookup", testUserContext.ID).Return(UserProfile{ID: testUserContext.ID}) // empty user profile
101+
decisionKey := NewUserDecisionKey(s.testDecisionContext.Experiment.ID)
102+
updatedUserProfile := UserProfile{
103+
ID: testUserContext.ID,
104+
ExperimentBucketMap: map[UserDecisionKey]string{decisionKey: s.testComputedDecision.Variation.ID},
105+
}
106+
107+
s.mockUserProfileService.On("Save", updatedUserProfile)
108+
persistingExperimentService := NewPersistingExperimentService(s.mockExperimentService, s.mockUserProfileService)
109+
decision, err := persistingExperimentService.GetDecision(s.testDecisionContext, testUserContext)
110+
s.Equal(s.testComputedDecision, decision)
111+
s.NoError(err)
112+
s.mockExperimentService.AssertExpectations(s.T())
113+
s.mockUserProfileService.AssertExpectations(s.T())
114+
}
115+
116+
func (s *PersistingExperimentServiceTestSuite) TestSavedVariationNoLongerValid() {
117+
decisionKey := NewUserDecisionKey(s.testDecisionContext.Experiment.ID)
118+
savedUserProfile := UserProfile{
119+
ID: testUserContext.ID,
120+
ExperimentBucketMap: map[UserDecisionKey]string{decisionKey: "forgotten_variation"},
121+
}
122+
s.mockUserProfileService.On("Lookup", testUserContext.ID).Return(savedUserProfile) // empty user profile
123+
124+
updatedUserProfile := UserProfile{
125+
ID: testUserContext.ID,
126+
ExperimentBucketMap: map[UserDecisionKey]string{decisionKey: s.testComputedDecision.Variation.ID},
127+
}
128+
s.mockUserProfileService.On("Save", updatedUserProfile)
129+
persistingExperimentService := NewPersistingExperimentService(s.mockExperimentService, s.mockUserProfileService)
130+
decision, err := persistingExperimentService.GetDecision(s.testDecisionContext, testUserContext)
131+
s.Equal(s.testComputedDecision, decision)
132+
s.NoError(err)
133+
s.mockExperimentService.AssertExpectations(s.T())
134+
s.mockUserProfileService.AssertExpectations(s.T())
135+
}
136+
137+
func TestPersistingExperimentServiceTestSuite(t *testing.T) {
138+
suite.Run(t, new(PersistingExperimentServiceTestSuite))
139+
}

0 commit comments

Comments
 (0)