Skip to content

Commit 3a3a045

Browse files
authored
[FSSDK-10764] add support for batch UPS for decideAll & decideForKeys (#394)
* add support for batch UPS for decideAll & decideForKeys * update decideForKeys logic Signed-off-by: pulak-opti <[email protected]> * refactor code Signed-off-by: pulak-opti <[email protected]> * fix bug Signed-off-by: pulak-opti <[email protected]> * use deepcopy for userprofile Signed-off-by: pulak-opti <[email protected]> * fix lint error Signed-off-by: pulak-opti <[email protected]> * fix bug Signed-off-by: pulak-opti <[email protected]> * fix Signed-off-by: pulak-opti <[email protected]> * fix Signed-off-by: pulak-opti <[email protected]> * fix * test Signed-off-by: pulak-opti <[email protected]> * refactor code Signed-off-by: pulak-opti <[email protected]> * fix Signed-off-by: pulak-opti <[email protected]> * save profile everytime Signed-off-by: pulak-opti <[email protected]> * update tests Signed-off-by: pulak-opti <[email protected]> * remove deepcopy method Signed-off-by: pulak-opti <[email protected]> * update unit tests Signed-off-by: pulak-opti <[email protected]> * update unit tests Signed-off-by: pulak-opti <[email protected]> * add unit tests --------- Signed-off-by: pulak-opti <[email protected]>
1 parent 6a21959 commit 3a3a045

File tree

7 files changed

+171
-16
lines changed

7 files changed

+171
-16
lines changed

pkg/client/client.go

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ type OptimizelyClient struct {
104104
ctx context.Context
105105
ConfigManager config.ProjectConfigManager
106106
DecisionService decision.Service
107+
UserProfileService decision.UserProfileService
107108
EventProcessor event.Processor
108109
OdpManager odp.Manager
109110
notificationCenter notification.Center
@@ -130,7 +131,7 @@ func (o *OptimizelyClient) WithTraceContext(ctx context.Context) *OptimizelyClie
130131
return o
131132
}
132133

133-
func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, options *decide.Options) OptimizelyDecision {
134+
func (o *OptimizelyClient) decide(userContext *OptimizelyUserContext, key string, options *decide.Options) OptimizelyDecision {
134135
var err error
135136
defer func() {
136137
if r := recover(); r != nil {
@@ -153,16 +154,17 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string,
153154

154155
decisionContext := decision.FeatureDecisionContext{
155156
ForcedDecisionService: userContext.forcedDecisionService,
157+
UserProfile: userContext.userProfile,
156158
}
157159
projectConfig, err := o.getProjectConfig()
158160
if err != nil {
159-
return NewErrorDecision(key, userContext, decide.GetDecideError(decide.SDKNotReady))
161+
return NewErrorDecision(key, *userContext, decide.GetDecideError(decide.SDKNotReady))
160162
}
161163
decisionContext.ProjectConfig = projectConfig
162164

163165
feature, err := projectConfig.GetFeatureByKey(key)
164166
if err != nil {
165-
return NewErrorDecision(key, userContext, decide.GetDecideError(decide.FlagKeyInvalid, key))
167+
return NewErrorDecision(key, *userContext, decide.GetDecideError(decide.FlagKeyInvalid, key))
166168
}
167169
decisionContext.Feature = &feature
168170

@@ -235,7 +237,7 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string,
235237
}
236238
}
237239

238-
return NewOptimizelyDecision(variationKey, ruleKey, key, flagEnabled, optimizelyJSON, userContext, reasonsToReport)
240+
return NewOptimizelyDecision(variationKey, ruleKey, key, flagEnabled, optimizelyJSON, *userContext, reasonsToReport)
239241
}
240242

241243
func (o *OptimizelyClient) decideForKeys(userContext OptimizelyUserContext, keys []string, options *decide.Options) map[string]OptimizelyDecision {
@@ -268,13 +270,30 @@ func (o *OptimizelyClient) decideForKeys(userContext OptimizelyUserContext, keys
268270
if len(keys) == 0 {
269271
return decisionMap
270272
}
273+
allOptions := o.getAllOptions(options)
271274

272-
enabledFlagsOnly := o.getAllOptions(options).EnabledFlagsOnly
273-
for _, key := range keys {
274-
optimizelyDecision := o.decide(userContext, key, options)
275-
if !enabledFlagsOnly || optimizelyDecision.Enabled {
276-
decisionMap[key] = optimizelyDecision
275+
var userProfile *decision.UserProfile
276+
ignoreUserProfileSvc := o.UserProfileService == nil || allOptions.IgnoreUserProfileService
277+
if !ignoreUserProfileSvc {
278+
up := o.UserProfileService.Lookup(userContext.GetUserID())
279+
if up.ID == "" {
280+
up = decision.UserProfile{
281+
ID: userContext.GetUserID(),
282+
ExperimentBucketMap: map[decision.UserDecisionKey]string{},
283+
}
277284
}
285+
userProfile = &up
286+
userContext.userProfile = userProfile
287+
}
288+
289+
for _, key := range keys {
290+
optimizelyDecision := o.decide(&userContext, key, options)
291+
decisionMap[key] = optimizelyDecision
292+
}
293+
294+
if !ignoreUserProfileSvc && userProfile != nil && userProfile.HasUnsavedChange {
295+
o.UserProfileService.Save(*userProfile)
296+
userProfile.HasUnsavedChange = false
278297
}
279298

280299
return decisionMap
@@ -1076,6 +1095,7 @@ func (o *OptimizelyClient) getExperimentDecision(experimentKey string, userConte
10761095
decisionContext = decision.ExperimentDecisionContext{
10771096
Experiment: &experiment,
10781097
ProjectConfig: projectConfig,
1098+
UserProfile: nil,
10791099
}
10801100

10811101
options := &decide.Options{}

pkg/client/factory.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ func (f *OptimizelyFactory) Client(clientOptions ...OptionFunc) (*OptimizelyClie
145145
appClient.EventProcessor = event.NewBatchEventProcessor(eventProcessorOptions...)
146146
}
147147

148+
if f.userProfileService != nil {
149+
appClient.UserProfileService = f.userProfileService
150+
}
151+
148152
if f.decisionService != nil {
149153
appClient.DecisionService = f.decisionService
150154
} else {

pkg/client/optimizely_user_context.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type OptimizelyUserContext struct {
3535
qualifiedSegments []string
3636
optimizely *OptimizelyClient
3737
forcedDecisionService *pkgDecision.ForcedDecisionService
38+
userProfile *pkgDecision.UserProfile
3839
mutex *sync.RWMutex
3940
}
4041

@@ -130,21 +131,31 @@ func (o *OptimizelyUserContext) IsQualifiedFor(segment string) bool {
130131
func (o *OptimizelyUserContext) Decide(key string, options []decide.OptimizelyDecideOptions) OptimizelyDecision {
131132
// use a copy of the user context so that any changes to the original context are not reflected inside the decision
132133
userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments())
133-
return o.optimizely.decide(userContextCopy, key, convertDecideOptions(options))
134+
decision, found := o.optimizely.decideForKeys(userContextCopy, []string{key}, convertDecideOptions(options))[key]
135+
if !found {
136+
return NewErrorDecision(key, *o, decide.GetDecideError(decide.SDKNotReady))
137+
}
138+
return decision
134139
}
135140

136141
// DecideAll returns a key-map of decision results for all active flag keys with options.
137142
func (o *OptimizelyUserContext) DecideAll(options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision {
138143
// use a copy of the user context so that any changes to the original context are not reflected inside the decision
139144
userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments())
140-
return o.optimizely.decideAll(userContextCopy, convertDecideOptions(options))
145+
decideOptions := convertDecideOptions(options)
146+
decisionMap := o.optimizely.decideAll(userContextCopy, decideOptions)
147+
148+
return filteredDecision(decisionMap, o.optimizely.getAllOptions(decideOptions).EnabledFlagsOnly)
141149
}
142150

143151
// DecideForKeys returns a key-map of decision results for multiple flag keys and options.
144152
func (o *OptimizelyUserContext) DecideForKeys(keys []string, options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision {
145153
// use a copy of the user context so that any changes to the original context are not reflected inside the decision
146154
userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments())
147-
return o.optimizely.decideForKeys(userContextCopy, keys, convertDecideOptions(options))
155+
decideOptions := convertDecideOptions(options)
156+
decisionMap := o.optimizely.decideForKeys(userContextCopy, keys, decideOptions)
157+
158+
return filteredDecision(decisionMap, o.optimizely.getAllOptions(decideOptions).EnabledFlagsOnly)
148159
}
149160

150161
// TrackEvent generates a conversion event with the given event key if it exists and queues it up to be sent to the Optimizely
@@ -208,3 +219,13 @@ func copyQualifiedSegments(qualifiedSegments []string) (qualifiedSegmentsCopy []
208219
copy(qualifiedSegmentsCopy, qualifiedSegments)
209220
return
210221
}
222+
223+
func filteredDecision(decisionMap map[string]OptimizelyDecision, enabledFlagsOnly bool) map[string]OptimizelyDecision {
224+
filteredDecision := make(map[string]OptimizelyDecision)
225+
for key, decision := range decisionMap {
226+
if !enabledFlagsOnly || decision.Enabled {
227+
filteredDecision[key] = decision
228+
}
229+
}
230+
return filteredDecision
231+
}

pkg/client/optimizely_user_context_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,97 @@ func (s *OptimizelyUserContextTestSuite) TestForcedDecision() {
12211221
s.Error(err)
12221222
}
12231223

1224+
func (s *OptimizelyUserContextTestSuite) TestDecideAllFlagsWithBatchUPS() {
1225+
userProfileService := new(MockUserProfileService)
1226+
var err error
1227+
s.OptimizelyClient, err = s.factory.Client(
1228+
WithEventProcessor(s.eventProcessor),
1229+
WithUserProfileService(userProfileService),
1230+
)
1231+
s.Nil(err)
1232+
1233+
savedUserProfile := decision.UserProfile{
1234+
ID: s.userID,
1235+
}
1236+
userProfileService.On("Lookup", s.userID).Return(savedUserProfile)
1237+
userProfileService.On("Save", mock.Anything)
1238+
1239+
user := s.OptimizelyClient.CreateUserContext(s.userID, nil)
1240+
decisions := user.DecideAll(nil)
1241+
s.Len(decisions, 3)
1242+
1243+
userProfileService.AssertNumberOfCalls(s.T(), "Lookup", 1)
1244+
userProfileService.AssertNumberOfCalls(s.T(), "Save", 1)
1245+
}
1246+
1247+
func (s *OptimizelyUserContextTestSuite) TestDecideForKeysWithBatchUPS() {
1248+
flagKey1 := "feature_1"
1249+
experimentID1 := "10390977673"
1250+
variationKey1 := "18257766532"
1251+
variationID1 := "variation_with_traffic"
1252+
flagKey2 := "feature_2" // embedding experiment: "exp_no_audience"
1253+
experimentID2 := "10420810910"
1254+
variationID2 := "10418510624"
1255+
variationKey2 := "variation_no_traffic"
1256+
userProfileService := new(MockUserProfileService)
1257+
var err error
1258+
s.OptimizelyClient, err = s.factory.Client(
1259+
WithEventProcessor(s.eventProcessor),
1260+
WithUserProfileService(userProfileService),
1261+
)
1262+
s.Nil(err)
1263+
1264+
savedUserProfile := decision.UserProfile{
1265+
ID: s.userID,
1266+
ExperimentBucketMap: map[decision.UserDecisionKey]string{
1267+
decision.NewUserDecisionKey(experimentID1): variationID1,
1268+
decision.NewUserDecisionKey(experimentID2): variationID2,
1269+
},
1270+
}
1271+
userProfileService.On("Lookup", s.userID).Return(savedUserProfile)
1272+
userProfileService.On("Save", mock.Anything)
1273+
1274+
user := s.OptimizelyClient.CreateUserContext(s.userID, nil)
1275+
decisions := user.DecideForKeys([]string{flagKey1, flagKey2}, nil)
1276+
s.Len(decisions, 2)
1277+
s.Equal(variationKey1, decisions[flagKey1].VariationKey)
1278+
s.Equal(variationKey2, decisions[flagKey2].VariationKey)
1279+
1280+
userProfileService.AssertNumberOfCalls(s.T(), "Lookup", 1)
1281+
userProfileService.AssertNumberOfCalls(s.T(), "Save", 0)
1282+
}
1283+
1284+
func (s *OptimizelyUserContextTestSuite) TestDecideWithBatchUPS() {
1285+
flagKey := "feature_2" // embedding experiment: "exp_no_audience"
1286+
experimentID := "10420810910"
1287+
variationID2 := "10418510624"
1288+
variationKey1 := "variation_no_traffic"
1289+
1290+
userProfileService := new(MockUserProfileService)
1291+
s.OptimizelyClient, _ = s.factory.Client(
1292+
WithEventProcessor(s.eventProcessor),
1293+
WithUserProfileService(userProfileService),
1294+
)
1295+
1296+
decisionKey := decision.NewUserDecisionKey(experimentID)
1297+
savedUserProfile := decision.UserProfile{
1298+
ID: s.userID,
1299+
ExperimentBucketMap: map[decision.UserDecisionKey]string{decisionKey: variationID2},
1300+
}
1301+
userProfileService.On("Lookup", s.userID).Return(savedUserProfile)
1302+
userProfileService.On("Save", mock.Anything)
1303+
1304+
client, err := s.factory.Client(WithUserProfileService(userProfileService))
1305+
s.Nil(err)
1306+
user := client.CreateUserContext(s.userID, nil)
1307+
decision := user.Decide(flagKey, []decide.OptimizelyDecideOptions{decide.IncludeReasons})
1308+
s.Len(decision.Reasons, 1)
1309+
1310+
s.Equal(variationKey1, decision.VariationKey)
1311+
userProfileService.AssertCalled(s.T(), "Lookup", s.userID)
1312+
userProfileService.AssertNotCalled(s.T(), "Save", mock.Anything)
1313+
}
1314+
12241315
func TestOptimizelyUserContextTestSuite(t *testing.T) {
12251316
suite.Run(t, new(OptimizelyUserContextTestSuite))
12261317
}

pkg/decision/entities.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
type ExperimentDecisionContext struct {
2828
Experiment *entities.Experiment
2929
ProjectConfig config.ProjectConfig
30+
UserProfile *UserProfile
3031
}
3132

3233
// FeatureDecisionContext contains the information needed to be able to make a decision for a given feature
@@ -35,6 +36,7 @@ type FeatureDecisionContext struct {
3536
ProjectConfig config.ProjectConfig
3637
Variable entities.Variable
3738
ForcedDecisionService *ForcedDecisionService
39+
UserProfile *UserProfile
3840
}
3941

4042
// UnsafeFeatureDecisionInfo represents response for GetDetailedFeatureDecisionUnsafe api
@@ -92,4 +94,5 @@ func NewUserDecisionKey(experimentID string) UserDecisionKey {
9294
type UserProfile struct {
9395
ID string
9496
ExperimentBucketMap map[UserDecisionKey]string
97+
HasUnsavedChange bool
9598
}

pkg/decision/feature_experiment_service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func (f FeatureExperimentService) GetDecision(decisionContext FeatureDecisionCon
6464
experimentDecisionContext := ExperimentDecisionContext{
6565
Experiment: &experiment,
6666
ProjectConfig: decisionContext.ProjectConfig,
67+
UserProfile: decisionContext.UserProfile,
6768
}
6869

6970
experimentDecision, decisionReasons, err := f.compositeExperimentService.GetDecision(experimentDecisionContext, userContext, options)

pkg/decision/persisting_experiment_service.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecis
5252
return p.experimentBucketedService.GetDecision(decisionContext, userContext, options)
5353
}
5454

55+
isUserProfileNil := decisionContext.UserProfile == nil
56+
5557
var userProfile UserProfile
5658
var decisionReasons decide.DecisionReasons
5759
// check to see if there is a saved decision for the user
@@ -66,7 +68,16 @@ func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecis
6668
if experimentDecision.Variation != nil {
6769
// save decision if a user profile service is provided
6870
userProfile.ID = userContext.ID
69-
p.saveDecision(userProfile, decisionContext.Experiment, experimentDecision)
71+
decisionKey := NewUserDecisionKey(decisionContext.Experiment.ID)
72+
if isUserProfileNil {
73+
p.saveDecision(userProfile, decisionKey, experimentDecision)
74+
} else {
75+
if decisionContext.UserProfile.ExperimentBucketMap == nil {
76+
decisionContext.UserProfile.ExperimentBucketMap = make(map[UserDecisionKey]string)
77+
}
78+
decisionContext.UserProfile.ExperimentBucketMap[decisionKey] = experimentDecision.Variation.ID
79+
decisionContext.UserProfile.HasUnsavedChange = true
80+
}
7081
}
7182

7283
return experimentDecision, reasons, err
@@ -75,7 +86,12 @@ func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecis
7586
func (p PersistingExperimentService) getSavedDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext, options *decide.Options) (ExperimentDecision, UserProfile, decide.DecisionReasons) {
7687
reasons := decide.NewDecisionReasons(options)
7788
experimentDecision := ExperimentDecision{}
78-
userProfile := p.userProfileService.Lookup(userContext.ID)
89+
var userProfile UserProfile
90+
if decisionContext.UserProfile == nil {
91+
userProfile = p.userProfileService.Lookup(userContext.ID)
92+
} else {
93+
userProfile = *decisionContext.UserProfile
94+
}
7995

8096
// look up experiment decision from user profile
8197
decisionKey := NewUserDecisionKey(decisionContext.Experiment.ID)
@@ -97,9 +113,8 @@ func (p PersistingExperimentService) getSavedDecision(decisionContext Experiment
97113
return experimentDecision, userProfile, reasons
98114
}
99115

100-
func (p PersistingExperimentService) saveDecision(userProfile UserProfile, experiment *entities.Experiment, decision ExperimentDecision) {
116+
func (p PersistingExperimentService) saveDecision(userProfile UserProfile, decisionKey UserDecisionKey, decision ExperimentDecision) {
101117
if p.userProfileService != nil {
102-
decisionKey := NewUserDecisionKey(experiment.ID)
103118
if userProfile.ExperimentBucketMap == nil {
104119
userProfile.ExperimentBucketMap = map[UserDecisionKey]string{}
105120
}

0 commit comments

Comments
 (0)