Skip to content

Commit

Permalink
[FSSDK-10764] add support for batch UPS for decideAll & decideForKeys (
Browse files Browse the repository at this point in the history
…#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]>
  • Loading branch information
pulak-opti authored Oct 30, 2024
1 parent 6a21959 commit 3a3a045
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 16 deletions.
38 changes: 29 additions & 9 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ type OptimizelyClient struct {
ctx context.Context
ConfigManager config.ProjectConfigManager
DecisionService decision.Service
UserProfileService decision.UserProfileService
EventProcessor event.Processor
OdpManager odp.Manager
notificationCenter notification.Center
Expand All @@ -130,7 +131,7 @@ func (o *OptimizelyClient) WithTraceContext(ctx context.Context) *OptimizelyClie
return o
}

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

decisionContext := decision.FeatureDecisionContext{
ForcedDecisionService: userContext.forcedDecisionService,
UserProfile: userContext.userProfile,
}
projectConfig, err := o.getProjectConfig()
if err != nil {
return NewErrorDecision(key, userContext, decide.GetDecideError(decide.SDKNotReady))
return NewErrorDecision(key, *userContext, decide.GetDecideError(decide.SDKNotReady))
}
decisionContext.ProjectConfig = projectConfig

feature, err := projectConfig.GetFeatureByKey(key)
if err != nil {
return NewErrorDecision(key, userContext, decide.GetDecideError(decide.FlagKeyInvalid, key))
return NewErrorDecision(key, *userContext, decide.GetDecideError(decide.FlagKeyInvalid, key))
}
decisionContext.Feature = &feature

Expand Down Expand Up @@ -235,7 +237,7 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string,
}
}

return NewOptimizelyDecision(variationKey, ruleKey, key, flagEnabled, optimizelyJSON, userContext, reasonsToReport)
return NewOptimizelyDecision(variationKey, ruleKey, key, flagEnabled, optimizelyJSON, *userContext, reasonsToReport)
}

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

enabledFlagsOnly := o.getAllOptions(options).EnabledFlagsOnly
for _, key := range keys {
optimizelyDecision := o.decide(userContext, key, options)
if !enabledFlagsOnly || optimizelyDecision.Enabled {
decisionMap[key] = optimizelyDecision
var userProfile *decision.UserProfile
ignoreUserProfileSvc := o.UserProfileService == nil || allOptions.IgnoreUserProfileService
if !ignoreUserProfileSvc {
up := o.UserProfileService.Lookup(userContext.GetUserID())
if up.ID == "" {
up = decision.UserProfile{
ID: userContext.GetUserID(),
ExperimentBucketMap: map[decision.UserDecisionKey]string{},
}
}
userProfile = &up
userContext.userProfile = userProfile
}

for _, key := range keys {
optimizelyDecision := o.decide(&userContext, key, options)
decisionMap[key] = optimizelyDecision
}

if !ignoreUserProfileSvc && userProfile != nil && userProfile.HasUnsavedChange {
o.UserProfileService.Save(*userProfile)
userProfile.HasUnsavedChange = false
}

return decisionMap
Expand Down Expand Up @@ -1076,6 +1095,7 @@ func (o *OptimizelyClient) getExperimentDecision(experimentKey string, userConte
decisionContext = decision.ExperimentDecisionContext{
Experiment: &experiment,
ProjectConfig: projectConfig,
UserProfile: nil,
}

options := &decide.Options{}
Expand Down
4 changes: 4 additions & 0 deletions pkg/client/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ func (f *OptimizelyFactory) Client(clientOptions ...OptionFunc) (*OptimizelyClie
appClient.EventProcessor = event.NewBatchEventProcessor(eventProcessorOptions...)
}

if f.userProfileService != nil {
appClient.UserProfileService = f.userProfileService
}

if f.decisionService != nil {
appClient.DecisionService = f.decisionService
} else {
Expand Down
27 changes: 24 additions & 3 deletions pkg/client/optimizely_user_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type OptimizelyUserContext struct {
qualifiedSegments []string
optimizely *OptimizelyClient
forcedDecisionService *pkgDecision.ForcedDecisionService
userProfile *pkgDecision.UserProfile
mutex *sync.RWMutex
}

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

// DecideAll returns a key-map of decision results for all active flag keys with options.
func (o *OptimizelyUserContext) DecideAll(options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision {
// use a copy of the user context so that any changes to the original context are not reflected inside the decision
userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments())
return o.optimizely.decideAll(userContextCopy, convertDecideOptions(options))
decideOptions := convertDecideOptions(options)
decisionMap := o.optimizely.decideAll(userContextCopy, decideOptions)

return filteredDecision(decisionMap, o.optimizely.getAllOptions(decideOptions).EnabledFlagsOnly)
}

// DecideForKeys returns a key-map of decision results for multiple flag keys and options.
func (o *OptimizelyUserContext) DecideForKeys(keys []string, options []decide.OptimizelyDecideOptions) map[string]OptimizelyDecision {
// use a copy of the user context so that any changes to the original context are not reflected inside the decision
userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService(), o.GetQualifiedSegments())
return o.optimizely.decideForKeys(userContextCopy, keys, convertDecideOptions(options))
decideOptions := convertDecideOptions(options)
decisionMap := o.optimizely.decideForKeys(userContextCopy, keys, decideOptions)

return filteredDecision(decisionMap, o.optimizely.getAllOptions(decideOptions).EnabledFlagsOnly)
}

// TrackEvent generates a conversion event with the given event key if it exists and queues it up to be sent to the Optimizely
Expand Down Expand Up @@ -208,3 +219,13 @@ func copyQualifiedSegments(qualifiedSegments []string) (qualifiedSegmentsCopy []
copy(qualifiedSegmentsCopy, qualifiedSegments)
return
}

func filteredDecision(decisionMap map[string]OptimizelyDecision, enabledFlagsOnly bool) map[string]OptimizelyDecision {
filteredDecision := make(map[string]OptimizelyDecision)
for key, decision := range decisionMap {
if !enabledFlagsOnly || decision.Enabled {
filteredDecision[key] = decision
}
}
return filteredDecision
}
91 changes: 91 additions & 0 deletions pkg/client/optimizely_user_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,97 @@ func (s *OptimizelyUserContextTestSuite) TestForcedDecision() {
s.Error(err)
}

func (s *OptimizelyUserContextTestSuite) TestDecideAllFlagsWithBatchUPS() {
userProfileService := new(MockUserProfileService)
var err error
s.OptimizelyClient, err = s.factory.Client(
WithEventProcessor(s.eventProcessor),
WithUserProfileService(userProfileService),
)
s.Nil(err)

savedUserProfile := decision.UserProfile{
ID: s.userID,
}
userProfileService.On("Lookup", s.userID).Return(savedUserProfile)
userProfileService.On("Save", mock.Anything)

user := s.OptimizelyClient.CreateUserContext(s.userID, nil)
decisions := user.DecideAll(nil)
s.Len(decisions, 3)

userProfileService.AssertNumberOfCalls(s.T(), "Lookup", 1)
userProfileService.AssertNumberOfCalls(s.T(), "Save", 1)
}

func (s *OptimizelyUserContextTestSuite) TestDecideForKeysWithBatchUPS() {
flagKey1 := "feature_1"
experimentID1 := "10390977673"
variationKey1 := "18257766532"
variationID1 := "variation_with_traffic"
flagKey2 := "feature_2" // embedding experiment: "exp_no_audience"
experimentID2 := "10420810910"
variationID2 := "10418510624"
variationKey2 := "variation_no_traffic"
userProfileService := new(MockUserProfileService)
var err error
s.OptimizelyClient, err = s.factory.Client(
WithEventProcessor(s.eventProcessor),
WithUserProfileService(userProfileService),
)
s.Nil(err)

savedUserProfile := decision.UserProfile{
ID: s.userID,
ExperimentBucketMap: map[decision.UserDecisionKey]string{
decision.NewUserDecisionKey(experimentID1): variationID1,
decision.NewUserDecisionKey(experimentID2): variationID2,
},
}
userProfileService.On("Lookup", s.userID).Return(savedUserProfile)
userProfileService.On("Save", mock.Anything)

user := s.OptimizelyClient.CreateUserContext(s.userID, nil)
decisions := user.DecideForKeys([]string{flagKey1, flagKey2}, nil)
s.Len(decisions, 2)
s.Equal(variationKey1, decisions[flagKey1].VariationKey)
s.Equal(variationKey2, decisions[flagKey2].VariationKey)

userProfileService.AssertNumberOfCalls(s.T(), "Lookup", 1)
userProfileService.AssertNumberOfCalls(s.T(), "Save", 0)
}

func (s *OptimizelyUserContextTestSuite) TestDecideWithBatchUPS() {
flagKey := "feature_2" // embedding experiment: "exp_no_audience"
experimentID := "10420810910"
variationID2 := "10418510624"
variationKey1 := "variation_no_traffic"

userProfileService := new(MockUserProfileService)
s.OptimizelyClient, _ = s.factory.Client(
WithEventProcessor(s.eventProcessor),
WithUserProfileService(userProfileService),
)

decisionKey := decision.NewUserDecisionKey(experimentID)
savedUserProfile := decision.UserProfile{
ID: s.userID,
ExperimentBucketMap: map[decision.UserDecisionKey]string{decisionKey: variationID2},
}
userProfileService.On("Lookup", s.userID).Return(savedUserProfile)
userProfileService.On("Save", mock.Anything)

client, err := s.factory.Client(WithUserProfileService(userProfileService))
s.Nil(err)
user := client.CreateUserContext(s.userID, nil)
decision := user.Decide(flagKey, []decide.OptimizelyDecideOptions{decide.IncludeReasons})
s.Len(decision.Reasons, 1)

s.Equal(variationKey1, decision.VariationKey)
userProfileService.AssertCalled(s.T(), "Lookup", s.userID)
userProfileService.AssertNotCalled(s.T(), "Save", mock.Anything)
}

func TestOptimizelyUserContextTestSuite(t *testing.T) {
suite.Run(t, new(OptimizelyUserContextTestSuite))
}
3 changes: 3 additions & 0 deletions pkg/decision/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
type ExperimentDecisionContext struct {
Experiment *entities.Experiment
ProjectConfig config.ProjectConfig
UserProfile *UserProfile
}

// FeatureDecisionContext contains the information needed to be able to make a decision for a given feature
Expand All @@ -35,6 +36,7 @@ type FeatureDecisionContext struct {
ProjectConfig config.ProjectConfig
Variable entities.Variable
ForcedDecisionService *ForcedDecisionService
UserProfile *UserProfile
}

// UnsafeFeatureDecisionInfo represents response for GetDetailedFeatureDecisionUnsafe api
Expand Down Expand Up @@ -92,4 +94,5 @@ func NewUserDecisionKey(experimentID string) UserDecisionKey {
type UserProfile struct {
ID string
ExperimentBucketMap map[UserDecisionKey]string
HasUnsavedChange bool
}
1 change: 1 addition & 0 deletions pkg/decision/feature_experiment_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func (f FeatureExperimentService) GetDecision(decisionContext FeatureDecisionCon
experimentDecisionContext := ExperimentDecisionContext{
Experiment: &experiment,
ProjectConfig: decisionContext.ProjectConfig,
UserProfile: decisionContext.UserProfile,
}

experimentDecision, decisionReasons, err := f.compositeExperimentService.GetDecision(experimentDecisionContext, userContext, options)
Expand Down
23 changes: 19 additions & 4 deletions pkg/decision/persisting_experiment_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecis
return p.experimentBucketedService.GetDecision(decisionContext, userContext, options)
}

isUserProfileNil := decisionContext.UserProfile == nil

var userProfile UserProfile
var decisionReasons decide.DecisionReasons
// check to see if there is a saved decision for the user
Expand All @@ -66,7 +68,16 @@ func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecis
if experimentDecision.Variation != nil {
// save decision if a user profile service is provided
userProfile.ID = userContext.ID
p.saveDecision(userProfile, decisionContext.Experiment, experimentDecision)
decisionKey := NewUserDecisionKey(decisionContext.Experiment.ID)
if isUserProfileNil {
p.saveDecision(userProfile, decisionKey, experimentDecision)
} else {
if decisionContext.UserProfile.ExperimentBucketMap == nil {
decisionContext.UserProfile.ExperimentBucketMap = make(map[UserDecisionKey]string)
}
decisionContext.UserProfile.ExperimentBucketMap[decisionKey] = experimentDecision.Variation.ID
decisionContext.UserProfile.HasUnsavedChange = true
}
}

return experimentDecision, reasons, err
Expand All @@ -75,7 +86,12 @@ func (p PersistingExperimentService) GetDecision(decisionContext ExperimentDecis
func (p PersistingExperimentService) getSavedDecision(decisionContext ExperimentDecisionContext, userContext entities.UserContext, options *decide.Options) (ExperimentDecision, UserProfile, decide.DecisionReasons) {
reasons := decide.NewDecisionReasons(options)
experimentDecision := ExperimentDecision{}
userProfile := p.userProfileService.Lookup(userContext.ID)
var userProfile UserProfile
if decisionContext.UserProfile == nil {
userProfile = p.userProfileService.Lookup(userContext.ID)
} else {
userProfile = *decisionContext.UserProfile
}

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

func (p PersistingExperimentService) saveDecision(userProfile UserProfile, experiment *entities.Experiment, decision ExperimentDecision) {
func (p PersistingExperimentService) saveDecision(userProfile UserProfile, decisionKey UserDecisionKey, decision ExperimentDecision) {
if p.userProfileService != nil {
decisionKey := NewUserDecisionKey(experiment.ID)
if userProfile.ExperimentBucketMap == nil {
userProfile.ExperimentBucketMap = map[UserDecisionKey]string{}
}
Expand Down

0 comments on commit 3a3a045

Please sign in to comment.