diff --git a/.travis.yml b/.travis.yml index 2cba2cdb5..9c5cac084 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,6 +36,7 @@ jobs: - <<: *test stage: 'Unit test' env: GIMME_GO_VERSION=1.10.x + dist: focal before_script: # GO module was not introduced earlier. need symlink to search in GOPATH - mkdir -p $GOPATH/src/github.com && pushd $GOPATH/src/github.com && ln -s $HOME/build/optimizely optimizely && popd diff --git a/go.mod b/go.mod index b6c5ec533..02d8a733d 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.12 require ( github.com/google/uuid v1.1.1 github.com/hashicorp/go-multierror v1.1.0 - github.com/json-iterator/go v1.1.7 + github.com/json-iterator/go v1.1.12 github.com/pkg/errors v0.8.1 github.com/pkg/profile v1.3.0 github.com/stretchr/testify v1.4.0 diff --git a/go.sum b/go.sum index 82e63093d..6cf66d232 100644 --- a/go.sum +++ b/go.sum @@ -9,12 +9,12 @@ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/U github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= -github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.3.0 h1:OQIvuDgm00gWVWGTf4m4mCt6W1/0YqU7Ntg0mySWgaI= diff --git a/pkg/client/client.go b/pkg/client/client.go index f33f95bcc..83ab892df 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -28,6 +28,7 @@ import ( "github.com/optimizely/go-sdk/pkg/config" "github.com/optimizely/go-sdk/pkg/decide" "github.com/optimizely/go-sdk/pkg/decision" + pkgReasons "github.com/optimizely/go-sdk/pkg/decision/reasons" "github.com/optimizely/go-sdk/pkg/entities" "github.com/optimizely/go-sdk/pkg/event" "github.com/optimizely/go-sdk/pkg/logging" @@ -52,7 +53,7 @@ type OptimizelyClient struct { // CreateUserContext creates a context of the user for which decision APIs will be called. // A user context will be created successfully even when the SDK is not fully configured yet. func (o *OptimizelyClient) CreateUserContext(userID string, attributes map[string]interface{}) OptimizelyUserContext { - return newOptimizelyUserContext(o, userID, attributes) + return newOptimizelyUserContext(o, userID, attributes, nil) } func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, options *decide.Options) OptimizelyDecision { @@ -73,7 +74,9 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, } }() - decisionContext := decision.FeatureDecisionContext{} + decisionContext := decision.FeatureDecisionContext{ + ForcedDecisionService: userContext.forcedDecisionService, + } projectConfig, err := o.getProjectConfig() if err != nil { return NewErrorDecision(key, userContext, decide.GetDecideError(decide.SDKNotReady)) @@ -95,9 +98,30 @@ func (o *OptimizelyClient) decide(userContext OptimizelyUserContext, key string, allOptions := o.getAllOptions(options) decisionReasons := decide.NewDecisionReasons(&allOptions) decisionContext.Variable = entities.Variable{} + var featureDecision decision.FeatureDecision + var reasons decide.DecisionReasons - featureDecision, reasons, err := o.DecisionService.GetFeatureDecision(decisionContext, usrContext, &allOptions) - decisionReasons.Append(reasons) + // To avoid cyclo-complexity warning + findRegularDecision := func() { + // regular decision + featureDecision, reasons, err = o.DecisionService.GetFeatureDecision(decisionContext, usrContext, &allOptions) + decisionReasons.Append(reasons) + } + + // check forced-decisions first + // Passing empty rule-key because checking mapping with flagKey only + if userContext.forcedDecisionService != nil { + var variation *entities.Variation + variation, reasons, err = userContext.forcedDecisionService.FindValidatedForcedDecision(projectConfig, key, "", &allOptions) + decisionReasons.Append(reasons) + if err != nil { + findRegularDecision() + } else { + featureDecision = decision.FeatureDecision{Decision: decision.Decision{Reason: pkgReasons.ForcedDecisionFound}, Variation: variation, Source: decision.FeatureTest} + } + } else { + findRegularDecision() + } if err != nil { o.logger.Warning(fmt.Sprintf(`Received error while making a decision for feature "%s": %s`, key, err)) diff --git a/pkg/client/optimizely_user_context.go b/pkg/client/optimizely_user_context.go index 4f0878e51..18cad79f5 100644 --- a/pkg/client/optimizely_user_context.go +++ b/pkg/client/optimizely_user_context.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020, Optimizely, Inc. and contributors * + * Copyright 2020-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -21,6 +21,7 @@ import ( "sync" "github.com/optimizely/go-sdk/pkg/decide" + "github.com/optimizely/go-sdk/pkg/decision" "github.com/optimizely/go-sdk/pkg/entities" ) @@ -29,23 +30,24 @@ type OptimizelyUserContext struct { UserID string `json:"userId"` Attributes map[string]interface{} `json:"attributes"` - optimizely *OptimizelyClient - mutex *sync.RWMutex + optimizely *OptimizelyClient + forcedDecisionService *decision.ForcedDecisionService + mutex *sync.RWMutex } // returns an instance of the optimizely user context. -func newOptimizelyUserContext(optimizely *OptimizelyClient, userID string, attributes map[string]interface{}) OptimizelyUserContext { +func newOptimizelyUserContext(optimizely *OptimizelyClient, userID string, attributes map[string]interface{}, forcedDecisionService *decision.ForcedDecisionService) OptimizelyUserContext { // store a copy of the provided attributes so it isn't affected by changes made afterwards. if attributes == nil { attributes = map[string]interface{}{} } attributesCopy := copyUserAttributes(attributes) - return OptimizelyUserContext{ - UserID: userID, - Attributes: attributesCopy, - optimizely: optimizely, - mutex: new(sync.RWMutex), + UserID: userID, + Attributes: attributesCopy, + optimizely: optimizely, + forcedDecisionService: forcedDecisionService, + mutex: new(sync.RWMutex), } } @@ -66,6 +68,13 @@ func (o OptimizelyUserContext) GetUserAttributes() map[string]interface{} { return copyUserAttributes(o.Attributes) } +func (o OptimizelyUserContext) getForcedDecisionService() *decision.ForcedDecisionService { + if o.forcedDecisionService != nil { + return o.forcedDecisionService.CreateCopy() + } + return nil +} + // SetAttribute sets an attribute for a given key. func (o *OptimizelyUserContext) SetAttribute(key string, value interface{}) { o.mutex.Lock() @@ -80,21 +89,21 @@ func (o *OptimizelyUserContext) SetAttribute(key string, value interface{}) { // all data required to deliver the flag or experiment. 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()) + userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService()) return o.optimizely.decide(userContextCopy, key, convertDecideOptions(options)) } // 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()) + userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService()) return o.optimizely.decideAll(userContextCopy, convertDecideOptions(options)) } // 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()) + userContextCopy := newOptimizelyUserContext(o.GetOptimizely(), o.GetUserID(), o.GetUserAttributes(), o.getForcedDecisionService()) return o.optimizely.decideForKeys(userContextCopy, keys, convertDecideOptions(options)) } @@ -108,6 +117,55 @@ func (o *OptimizelyUserContext) TrackEvent(eventKey string, eventTags map[string return o.optimizely.Track(eventKey, userContext, eventTags) } +// SetForcedDecision sets the forced decision (variation key) for a given flag and an optional rule. +// returns true if the forced decision has been set successfully. +func (o *OptimizelyUserContext) SetForcedDecision(flagKey, ruleKey, variationKey string) bool { + if _, err := o.optimizely.getProjectConfig(); err != nil { + o.optimizely.logger.Error("Optimizely instance is not valid, failing setForcedDecision call.", err) + return false + } + if o.forcedDecisionService == nil { + o.forcedDecisionService = decision.NewForcedDecisionService(o.GetUserID()) + } + return o.forcedDecisionService.SetForcedDecision(flagKey, ruleKey, variationKey) +} + +// GetForcedDecision returns the forced decision for a given flag and an optional rule +func (o *OptimizelyUserContext) GetForcedDecision(flagKey, ruleKey string) string { + if _, err := o.optimizely.getProjectConfig(); err != nil { + o.optimizely.logger.Error("Optimizely instance is not valid, failing getForcedDecision call.", err) + return "" + } + if o.forcedDecisionService == nil { + return "" + } + return o.forcedDecisionService.GetForcedDecision(flagKey, ruleKey) +} + +// RemoveForcedDecision removes the forced decision for a given flag and an optional rule. +func (o *OptimizelyUserContext) RemoveForcedDecision(flagKey, ruleKey string) bool { + if _, err := o.optimizely.getProjectConfig(); err != nil { + o.optimizely.logger.Error("Optimizely instance is not valid, failing removeForcedDecision call.", err) + return false + } + if o.forcedDecisionService == nil { + return false + } + return o.forcedDecisionService.RemoveForcedDecision(flagKey, ruleKey) +} + +// RemoveAllForcedDecisions removes all forced decisions bound to this user context. +func (o *OptimizelyUserContext) RemoveAllForcedDecisions() bool { + if _, err := o.optimizely.getProjectConfig(); err != nil { + o.optimizely.logger.Error("Optimizely instance is not valid, failing removeForcedDecision call.", err) + return false + } + if o.forcedDecisionService == nil { + return true + } + return o.forcedDecisionService.RemoveAllForcedDecisions() +} + func copyUserAttributes(attributes map[string]interface{}) (attributesCopy map[string]interface{}) { if attributes != nil { attributesCopy = make(map[string]interface{}) diff --git a/pkg/client/optimizely_user_context_test.go b/pkg/client/optimizely_user_context_test.go index a0c6c8d9a..0c7a9678d 100644 --- a/pkg/client/optimizely_user_context_test.go +++ b/pkg/client/optimizely_user_context_test.go @@ -57,16 +57,17 @@ func (s *OptimizelyUserContextTestSuite) SetupTest() { func (s *OptimizelyUserContextTestSuite) TestOptimizelyUserContextWithAttributes() { attributes := map[string]interface{}{"key1": 1212, "key2": 1213} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(s.userID, optimizelyUserContext.GetUserID()) s.Equal(attributes, optimizelyUserContext.GetUserAttributes()) + s.Nil(optimizelyUserContext.forcedDecisionService) } func (s *OptimizelyUserContextTestSuite) TestOptimizelyUserContextNoAttributes() { attributes := map[string]interface{}{} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(s.userID, optimizelyUserContext.GetUserID()) @@ -75,7 +76,7 @@ func (s *OptimizelyUserContextTestSuite) TestOptimizelyUserContextNoAttributes() func (s *OptimizelyUserContextTestSuite) TestUpatingProvidedUserContextHasNoImpactOnOptimizelyUserContext() { attributes := map[string]interface{}{"k1": "v1", "k2": false} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, s.userID, attributes, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(s.userID, optimizelyUserContext.GetUserID()) @@ -99,7 +100,7 @@ func (s *OptimizelyUserContextTestSuite) TestSetAttribute() { userID := "1212121" var attributes map[string]interface{} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) var wg sync.WaitGroup @@ -125,7 +126,7 @@ func (s *OptimizelyUserContextTestSuite) TestSetAttribute() { func (s *OptimizelyUserContextTestSuite) TestSetAttributeOverride() { userID := "1212121" attributes := map[string]interface{}{"k1": "v1", "k2": false} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(userID, optimizelyUserContext.GetUserID()) @@ -141,7 +142,7 @@ func (s *OptimizelyUserContextTestSuite) TestSetAttributeOverride() { func (s *OptimizelyUserContextTestSuite) TestSetAttributeNullValue() { userID := "1212121" attributes := map[string]interface{}{"k1": nil} - optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes) + optimizelyUserContext := newOptimizelyUserContext(s.OptimizelyClient, userID, attributes, nil) s.Equal(s.OptimizelyClient, optimizelyUserContext.GetOptimizely()) s.Equal(userID, optimizelyUserContext.GetUserID()) @@ -199,6 +200,107 @@ func (s *OptimizelyUserContextTestSuite) TestDecideFeatureTest() { s.Equal("10418551353", impressionEvent.VariationID) } +func (s *OptimizelyUserContextTestSuite) TestDecideFeatureTestWithForcedDecision() { + numberOfNotifications := 0 + testForcedDecision := func(flagKey, ruleKey, experimentID, variationKey, reason string, expectedEventCount int) { + variablesExpected, err := s.OptimizelyClient.GetAllFeatureVariables(flagKey, entities.UserContext{ID: s.userID}) + s.Nil(err) + + note := notification.DecisionNotification{} + callback := func(notification notification.DecisionNotification) { + note = notification + numberOfNotifications++ + } + notificationID, err := s.OptimizelyClient.DecisionService.OnDecision(callback) + s.NoError(err) + + user := s.OptimizelyClient.CreateUserContext(s.userID, nil) + user.SetForcedDecision(flagKey, ruleKey, variationKey) + decision := user.Decide(flagKey, []decide.OptimizelyDecideOptions{decide.IncludeReasons}) + s.OptimizelyClient.DecisionService.RemoveOnDecision(notificationID) + + s.Equal(variationKey, decision.VariationKey) + s.Equal(false, decision.Enabled) + s.Equal(variablesExpected.ToMap(), decision.Variables.ToMap()) + s.Equal(ruleKey, decision.RuleKey) + s.Equal(flagKey, decision.FlagKey) + s.Equal(user, decision.UserContext) + reasons := decision.Reasons + s.Len(reasons, 1) + s.Equal(reason, reasons[0]) + + s.True(len(s.eventProcessor.Events) == expectedEventCount) + s.Equal(s.userID, s.eventProcessor.Events[expectedEventCount-1].VisitorID) + + impressionEvent := s.eventProcessor.Events[expectedEventCount-1].Impression + s.Equal(flagKey, impressionEvent.Metadata.FlagKey) + s.Equal(ruleKey, impressionEvent.Metadata.RuleKey) + s.Equal("feature-test", impressionEvent.Metadata.RuleType) + s.Equal(variationKey, impressionEvent.Metadata.VariationKey) + s.Equal(false, impressionEvent.Metadata.Enabled) + s.Equal(experimentID, impressionEvent.ExperimentID) + s.Equal("10416523121", impressionEvent.VariationID) + + // Checking notification data + s.Equal(note.DecisionInfo["flagKey"], impressionEvent.Metadata.FlagKey) + s.Equal(note.DecisionInfo["ruleKey"], impressionEvent.Metadata.RuleKey) + s.Equal(note.DecisionInfo["enabled"], impressionEvent.Metadata.Enabled) + s.Equal(note.DecisionInfo["variationKey"], impressionEvent.Metadata.VariationKey) + } + + // valid rule key + expectedEventCount := 1 + flagKey := "feature_1" + ruleKey := "exp_with_audience" + experimentID := "10390977673" + variationKey := "b" + reason := `Variation (b) is mapped to flag (feature_1), rule (exp_with_audience) and user (tester) in the forced decision map.` + testForcedDecision(flagKey, ruleKey, experimentID, variationKey, reason, expectedEventCount) + + // empty rule key + expectedEventCount = 2 + ruleKey = "" + experimentID = "" + reason = `Variation (b) is mapped to flag (feature_1) and user (tester) in the forced decision map.` + testForcedDecision(flagKey, ruleKey, experimentID, variationKey, reason, expectedEventCount) + + s.Equal(2, numberOfNotifications) +} + +func (s *OptimizelyUserContextTestSuite) TestDecideFeatureTestWithForcedDecisionEmptyRuleKey() { + flagKey := "feature_1" + ruleKey := "" + variationKey := "b" + variablesExpected, err := s.OptimizelyClient.GetAllFeatureVariables(flagKey, entities.UserContext{ID: s.userID}) + s.Nil(err) + + user := s.OptimizelyClient.CreateUserContext(s.userID, nil) + user.SetForcedDecision(flagKey, ruleKey, variationKey) + decision := user.Decide(flagKey, []decide.OptimizelyDecideOptions{decide.IncludeReasons}) + + s.Equal(variationKey, decision.VariationKey) + s.Equal(false, decision.Enabled) + s.Equal(variablesExpected.ToMap(), decision.Variables.ToMap()) + s.Equal(ruleKey, decision.RuleKey) + s.Equal(flagKey, decision.FlagKey) + s.Equal(user, decision.UserContext) + reasons := decision.Reasons + s.Len(reasons, 1) + s.Equal(`Variation (b) is mapped to flag (feature_1) and user (tester) in the forced decision map.`, reasons[0]) + + s.True(len(s.eventProcessor.Events) == 1) + s.Equal(s.userID, s.eventProcessor.Events[0].VisitorID) + + impressionEvent := s.eventProcessor.Events[0].Impression + s.Equal(flagKey, impressionEvent.Metadata.FlagKey) + s.Equal(ruleKey, impressionEvent.Metadata.RuleKey) + s.Equal("feature-test", impressionEvent.Metadata.RuleType) + s.Equal(variationKey, impressionEvent.Metadata.VariationKey) + s.Equal(false, impressionEvent.Metadata.Enabled) + s.Equal("", impressionEvent.ExperimentID) + s.Equal("10416523121", impressionEvent.VariationID) +} + func (s *OptimizelyUserContextTestSuite) TestDecideRollout() { flagKey := "feature_1" ruleKey := "18322080788" @@ -247,6 +349,50 @@ func (s *OptimizelyUserContextTestSuite) TestDecideRollout() { s.Equal("18257766532", impressionEvent.VariationID) } +func (s *OptimizelyUserContextTestSuite) TestDecideRolloutWithForcedDecision() { + flagKey := "feature_1" + ruleKey := "3332020515" + variationKey := "3324490633" + variablesExpected, err := s.OptimizelyClient.GetAllFeatureVariables(flagKey, entities.UserContext{ID: s.userID}) + s.Nil(err) + + user := s.OptimizelyClient.CreateUserContext(s.userID, nil) + user.SetForcedDecision(flagKey, ruleKey, variationKey) + decision := user.Decide(flagKey, []decide.OptimizelyDecideOptions{decide.IncludeReasons}) + + s.Equal(variationKey, decision.VariationKey) + s.Equal(true, decision.Enabled) + s.Equal(variablesExpected.ToMap(), decision.Variables.ToMap()) + s.Equal(ruleKey, decision.RuleKey) + s.Equal(flagKey, decision.FlagKey) + s.Equal(user, decision.UserContext) + reasons := decision.Reasons + s.Len(reasons, 4) + + expectedLogs := []string{ + `an error occurred while evaluating nested tree for audience ID "13389141123"`, + `Audiences for experiment exp_with_audience collectively evaluated to false.`, + `User "tester" does not meet conditions to be in experiment "exp_with_audience".`, + `Variation (3324490633) is mapped to flag (feature_1), rule (3332020515) and user (tester) in the forced decision map.`, + } + + for index, log := range expectedLogs { + s.Equal(log, reasons[index]) + } + + s.True(len(s.eventProcessor.Events) == 1) + s.Equal(s.userID, s.eventProcessor.Events[0].VisitorID) + + impressionEvent := s.eventProcessor.Events[0].Impression + s.Equal(flagKey, impressionEvent.Metadata.FlagKey) + s.Equal(ruleKey, impressionEvent.Metadata.RuleKey) + s.Equal("rollout", impressionEvent.Metadata.RuleType) + s.Equal(variationKey, impressionEvent.Metadata.VariationKey) + s.Equal(true, impressionEvent.Metadata.Enabled) + s.Equal("3332020515", impressionEvent.ExperimentID) + s.Equal("3324490633", impressionEvent.VariationID) +} + func (s *OptimizelyUserContextTestSuite) TestDecideNullVariation() { flagKey := "feature_3" variablesExpected := optimizelyjson.NewOptimizelyJSONfromMap(map[string]interface{}{}) @@ -906,6 +1052,56 @@ func (s *OptimizelyUserContextTestSuite) TestDecideForKeysErrorDecisionIncluded( s.Equal(decide.GetDecideMessage(decide.FlagKeyInvalid, flagKey2), reasons[0]) } +func (s *OptimizelyUserContextTestSuite) TestForcedDecisionWithNilConfig() { + s.OptimizelyClient.ConfigManager = nil + + flagKeyA := "feature_1" + ruleKey := "" + variationKeyA := "a" + + // checking with nil forcedDecisionService + user := s.OptimizelyClient.CreateUserContext(s.userID, nil) + s.Nil(user.forcedDecisionService) + + s.False(user.SetForcedDecision(flagKeyA, ruleKey, variationKeyA)) + s.Nil(user.forcedDecisionService) + + s.Equal("", user.GetForcedDecision(flagKeyA, ruleKey)) + s.False(user.RemoveForcedDecision(flagKeyA, ruleKey)) + s.False(user.RemoveAllForcedDecisions()) +} + +func (s *OptimizelyUserContextTestSuite) TestForcedDecision() { + flagKeyA := "feature_1" + flagKeyB := "feature_2" + ruleKey := "" + variationKeyA := "a" + variationKeyB := "b" + + // checking with nil forcedDecisionService + user := s.OptimizelyClient.CreateUserContext(s.userID, nil) + s.Nil(user.forcedDecisionService) + s.Equal("", user.GetForcedDecision(flagKeyA, ruleKey)) + s.False(user.RemoveForcedDecision(flagKeyA, ruleKey)) + s.True(user.RemoveAllForcedDecisions()) + + // checking if forcedDecisionService was created using SetForcedDecision + s.True(user.SetForcedDecision(flagKeyA, ruleKey, variationKeyA)) + s.NotNil(user.forcedDecisionService) + + s.True(user.SetForcedDecision(flagKeyB, ruleKey, variationKeyB)) + s.Equal(variationKeyA, user.GetForcedDecision(flagKeyA, ruleKey)) + s.Equal(variationKeyB, user.GetForcedDecision(flagKeyB, ruleKey)) + + s.True(user.RemoveForcedDecision(flagKeyA, ruleKey)) + s.Equal("", user.GetForcedDecision(flagKeyA, ruleKey)) + s.Equal(variationKeyB, user.GetForcedDecision(flagKeyB, ruleKey)) + + s.True(user.RemoveAllForcedDecisions()) + s.Equal("", user.GetForcedDecision(flagKeyA, ruleKey)) + s.Equal("", user.GetForcedDecision(flagKeyB, ruleKey)) +} + func TestOptimizelyUserContextTestSuite(t *testing.T) { suite.Run(t, new(OptimizelyUserContextTestSuite)) } diff --git a/pkg/config/datafileprojectconfig/config.go b/pkg/config/datafileprojectconfig/config.go index 203ab4680..544b05f96 100644 --- a/pkg/config/datafileprojectconfig/config.go +++ b/pkg/config/datafileprojectconfig/config.go @@ -51,6 +51,8 @@ type DatafileProjectConfig struct { sendFlagDecisions bool sdkKey string environmentKey string + + flagVariationsMap map[string][]entities.Variation } // GetDatafile returns a string representation of the environment's datafile @@ -226,6 +228,11 @@ func (c DatafileProjectConfig) SendFlagDecisions() bool { return c.sendFlagDecisions } +// GetFlagVariationsMap returns map containing all variations for each flag +func (c DatafileProjectConfig) GetFlagVariationsMap() map[string][]entities.Variation { + return c.flagVariationsMap +} + // NewDatafileProjectConfig initializes a new datafile from a json byte array using the default JSON datafile parser func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogProducer) (*DatafileProjectConfig, error) { datafile, err := Parse(jsonDatafile) @@ -250,6 +257,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP mergedAudiences := append(datafile.TypedAudiences, datafile.Audiences...) featureMap := mappers.MapFeatures(datafile.FeatureFlags, rolloutMap, experimentIDMap) audienceMap := mappers.MapAudiences(mergedAudiences) + flagVariationsMap := mappers.MapFlagVariations(featureMap) config := &DatafileProjectConfig{ datafile: string(jsonDatafile), @@ -271,6 +279,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP rollouts: rollouts, rolloutMap: rolloutMap, sendFlagDecisions: datafile.SendFlagDecisions, + flagVariationsMap: flagVariationsMap, } logger.Info("Datafile is valid.") diff --git a/pkg/config/datafileprojectconfig/config_test.go b/pkg/config/datafileprojectconfig/config_test.go index bd03cc191..3880a0921 100644 --- a/pkg/config/datafileprojectconfig/config_test.go +++ b/pkg/config/datafileprojectconfig/config_test.go @@ -19,6 +19,8 @@ package datafileprojectconfig import ( "fmt" + "io/ioutil" + "path/filepath" "testing" "github.com/optimizely/go-sdk/pkg/entities" @@ -462,3 +464,25 @@ func TestGetGroupByIDMissingIDError(t *testing.T) { assert.Equal(t, fmt.Errorf(`group with ID "id" not found`), err) } } + +func TestGetFlagVariationsMap(t *testing.T) { + absPath, _ := filepath.Abs("../../../test-data/decide-test-datafile.json") + datafile, err := ioutil.ReadFile(absPath) + assert.NoError(t, err) + config, err := NewDatafileProjectConfig(datafile, logging.GetLogger("", "")) + assert.NoError(t, err) + flagVariationsMap := config.GetFlagVariationsMap() + + variationsMap := map[string]bool{"a": true, "b": true, "3324490633": true, "3324490562": true, "18257766532": true} + for _, variation := range flagVariationsMap["feature_1"] { + assert.True(t, variationsMap[variation.Key]) + } + + variationsMap = map[string]bool{"variation_with_traffic": true, "variation_no_traffic": true} + for _, variation := range flagVariationsMap["feature_2"] { + assert.True(t, variationsMap[variation.Key]) + } + + assert.NotNil(t, flagVariationsMap["feature_3"]) + assert.Len(t, flagVariationsMap["feature_3"], 0) +} diff --git a/pkg/config/datafileprojectconfig/mappers/forced_decision.go b/pkg/config/datafileprojectconfig/mappers/forced_decision.go new file mode 100644 index 000000000..e7fe0eeb4 --- /dev/null +++ b/pkg/config/datafileprojectconfig/mappers/forced_decision.go @@ -0,0 +1,49 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package mappers ... +package mappers + +import ( + "github.com/optimizely/go-sdk/pkg/entities" +) + +// MapFlagVariations all variations for each flag +// datafile does not contain a separate entity for this +// we collect variations used in each rule (experiment rules and delivery rules) +func MapFlagVariations(featureMap map[string]entities.Feature) (flagVariationsMap map[string][]entities.Variation) { + flagVariationsMap = map[string][]entities.Variation{} + for _, flag := range featureMap { + // To track if variation was already added to list + variationsTracker := map[string]bool{} + variations := []entities.Variation{} + + allRulesForFlag := []entities.Experiment{} + allRulesForFlag = append(allRulesForFlag, flag.FeatureExperiments...) + allRulesForFlag = append(allRulesForFlag, flag.Rollout.Experiments...) + + for _, rule := range allRulesForFlag { + for _, variation := range rule.Variations { + if !variationsTracker[variation.ID] { + variationsTracker[variation.ID] = true + variations = append(variations, variation) + } + } + } + flagVariationsMap[flag.Key] = variations + } + return flagVariationsMap +} diff --git a/pkg/config/interface.go b/pkg/config/interface.go index 244f41302..8b2ddb54f 100644 --- a/pkg/config/interface.go +++ b/pkg/config/interface.go @@ -48,6 +48,7 @@ type ProjectConfig interface { GetSdkKey() string GetEnvironmentKey() string GetAttributes() []entities.Attribute + GetFlagVariationsMap() map[string][]entities.Variation } // ProjectConfigManager maintains an instance of the ProjectConfig diff --git a/pkg/decision/entities.go b/pkg/decision/entities.go index 44712b4f1..10884ac9f 100644 --- a/pkg/decision/entities.go +++ b/pkg/decision/entities.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -31,9 +31,10 @@ type ExperimentDecisionContext struct { // FeatureDecisionContext contains the information needed to be able to make a decision for a given feature type FeatureDecisionContext struct { - Feature *entities.Feature - ProjectConfig config.ProjectConfig - Variable entities.Variable + Feature *entities.Feature + ProjectConfig config.ProjectConfig + Variable entities.Variable + ForcedDecisionService *ForcedDecisionService } // UnsafeFeatureDecisionInfo represents response for GetDetailedFeatureDecisionUnsafe api diff --git a/pkg/decision/feature_experiment_service.go b/pkg/decision/feature_experiment_service.go index fc79b4624..ebfd45aa9 100644 --- a/pkg/decision/feature_experiment_service.go +++ b/pkg/decision/feature_experiment_service.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -45,6 +45,21 @@ func (f FeatureExperimentService) GetDecision(decisionContext FeatureDecisionCon reasons := decide.NewDecisionReasons(options) // @TODO this can be improved by getting group ID first and determining experiment and then bucketing in experiment for _, featureExperiment := range feature.FeatureExperiments { + + // Checking for forced decision + if decisionContext.ForcedDecisionService != nil { + forcedDecision, _reasons, err := decisionContext.ForcedDecisionService.FindValidatedForcedDecision(decisionContext.ProjectConfig, feature.Key, featureExperiment.Key, options) + reasons.Append(_reasons) + if err == nil { + featureDecision := FeatureDecision{ + Experiment: featureExperiment, + Variation: forcedDecision, + Source: FeatureTest, + } + return featureDecision, reasons, nil + } + } + experiment := featureExperiment experimentDecisionContext := ExperimentDecisionContext{ Experiment: &experiment, diff --git a/pkg/decision/feature_experiment_service_test.go b/pkg/decision/feature_experiment_service_test.go index dee690518..d32d85592 100644 --- a/pkg/decision/feature_experiment_service_test.go +++ b/pkg/decision/feature_experiment_service_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -38,9 +38,10 @@ type FeatureExperimentServiceTestSuite struct { func (s *FeatureExperimentServiceTestSuite) SetupTest() { s.mockConfig = new(mockProjectConfig) s.testFeatureDecisionContext = FeatureDecisionContext{ - Feature: &testFeat3335, - ProjectConfig: s.mockConfig, - Variable: testVariable, + Feature: &testFeat3335, + ProjectConfig: s.mockConfig, + Variable: testVariable, + ForcedDecisionService: NewForcedDecisionService("test_user"), } s.mockExperimentService = new(MockExperimentDecisionService) s.options = &decide.Options{} @@ -78,6 +79,59 @@ func (s *FeatureExperimentServiceTestSuite) TestGetDecision() { s.mockExperimentService.AssertExpectations(s.T()) } +func (s *FeatureExperimentServiceTestSuite) TestGetDecisionWithForcedDecision() { + testUserContext := entities.UserContext{ + ID: "test_user_1", + } + + expectedVariation := testExp1113.Variations["2223"] + flagVariationsMap := map[string][]entities.Variation{ + s.testFeatureDecisionContext.Feature.Key: { + expectedVariation, + }, + } + s.mockConfig.On("GetFlagVariationsMap").Return(flagVariationsMap) + s.testFeatureDecisionContext.ForcedDecisionService.SetForcedDecision(s.testFeatureDecisionContext.Feature.Key, testExp1113Key, expectedVariation.Key) + + testExperimentDecisionContext := ExperimentDecisionContext{ + Experiment: &testExp1113, + ProjectConfig: s.mockConfig, + } + + featureExperimentService := &FeatureExperimentService{ + compositeExperimentService: s.mockExperimentService, + logger: logging.GetLogger("sdkKey", "FeatureExperimentService"), + } + + expectedFeatureDecision := FeatureDecision{ + Experiment: *testExperimentDecisionContext.Experiment, + Variation: &expectedVariation, + Source: FeatureTest, + } + options := &decide.Options{IncludeReasons: true} + decision, reasons, err := featureExperimentService.GetDecision(s.testFeatureDecisionContext, testUserContext, options) + s.Equal(expectedFeatureDecision, decision) + s.Equal(expectedFeatureDecision, decision) + s.Equal("Variation (2223) is mapped to flag (test_feature_3335_key), rule (test_experiment_1113) and user (test_user) in the forced decision map.", reasons.ToReport()[0]) + s.NoError(err) + // Makes sure that decision returned was a forcedDecision + s.mockExperimentService.AssertNotCalled(s.T(), "GetDecision", testExperimentDecisionContext, testUserContext, options) + + // invalid forced decision + s.testFeatureDecisionContext.ForcedDecisionService.SetForcedDecision(s.testFeatureDecisionContext.Feature.Key, testExp1113Key, "invalid") + + expectedVariation = testExp1113.Variations["2223"] + returnExperimentDecision := ExperimentDecision{ + Variation: &expectedVariation, + } + s.mockExperimentService.On("GetDecision", testExperimentDecisionContext, testUserContext, options).Return(returnExperimentDecision, s.reasons, nil) + decision, reasons, err = featureExperimentService.GetDecision(s.testFeatureDecisionContext, testUserContext, options) + s.Equal(expectedFeatureDecision, decision) + s.Equal("Invalid variation is mapped to flag (test_feature_3335_key), rule (test_experiment_1113) and user (test_user) in the forced decision map.", reasons.ToReport()[0]) + s.NoError(err) + s.mockExperimentService.AssertExpectations(s.T()) +} + func (s *FeatureExperimentServiceTestSuite) TestGetDecisionMutex() { testUserContext := entities.UserContext{ ID: "test_user_1", diff --git a/pkg/decision/forced_decision_service.go b/pkg/decision/forced_decision_service.go new file mode 100644 index 000000000..744b1cf5c --- /dev/null +++ b/pkg/decision/forced_decision_service.go @@ -0,0 +1,144 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package decision // +package decision + +import ( + "errors" + "sync" + + "github.com/optimizely/go-sdk/pkg/config" + "github.com/optimizely/go-sdk/pkg/decide" + "github.com/optimizely/go-sdk/pkg/entities" +) + +type forcedDecision struct { + flagKey string + ruleKey string +} + +// ForcedDecisionService defines user contexts that the SDK will use to make decisions for. +type ForcedDecisionService struct { + UserID string + forcedDecisions map[forcedDecision]string + mutex *sync.RWMutex +} + +// NewForcedDecisionService returns an instance of the optimizely user context. +func NewForcedDecisionService(userID string) *ForcedDecisionService { + return &ForcedDecisionService{ + UserID: userID, + forcedDecisions: map[forcedDecision]string{}, + mutex: new(sync.RWMutex), + } +} + +// SetForcedDecision sets the forced decision (variation key) for a given flag and an optional rule. +// if rule key is empty, forced decision will be mapped against the flagKey. +// returns true if the forced decision has been set successfully. +func (f *ForcedDecisionService) SetForcedDecision(flagKey, ruleKey, variationKey string) bool { + if flagKey == "" { + return false + } + f.mutex.Lock() + defer f.mutex.Unlock() + f.forcedDecisions[forcedDecision{flagKey: flagKey, ruleKey: ruleKey}] = variationKey + return true +} + +// GetForcedDecision returns the forced decision for a given flag and an optional rule +// if rule key is empty, forced decision will be returned for the flagKey. +func (f *ForcedDecisionService) GetForcedDecision(flagKey, ruleKey string) string { + f.mutex.RLock() + defer f.mutex.RUnlock() + if len(f.forcedDecisions) == 0 { + return "" + } + if variationKey, ok := f.forcedDecisions[forcedDecision{flagKey: flagKey, ruleKey: ruleKey}]; ok { + return variationKey + } + return "" +} + +// RemoveForcedDecision removes the forced decision for a given flag and an optional rule. +// if rule key is empty, forced decision will be removed for the flagKey. +func (f *ForcedDecisionService) RemoveForcedDecision(flagKey, ruleKey string) bool { + f.mutex.Lock() + defer f.mutex.Unlock() + decision := forcedDecision{flagKey: flagKey, ruleKey: ruleKey} + if f.forcedDecisions[decision] != "" { + f.forcedDecisions[decision] = "" + return true + } + return false +} + +// RemoveAllForcedDecisions removes all forced decisions bound to this user context. +func (f *ForcedDecisionService) RemoveAllForcedDecisions() bool { + f.mutex.Lock() + defer f.mutex.Unlock() + f.forcedDecisions = map[forcedDecision]string{} + return true +} + +// FindValidatedForcedDecision returns validated forced decision. +func (f *ForcedDecisionService) FindValidatedForcedDecision(projectConfig config.ProjectConfig, flagKey, ruleKey string, options *decide.Options) (variation *entities.Variation, reasons decide.DecisionReasons, err error) { + decisionReasons := decide.NewDecisionReasons(options) + variationKey := f.GetForcedDecision(flagKey, ruleKey) + if variationKey == "" { + return nil, decisionReasons, errors.New("decision not found") + } + + _variation, err := f.getFlagVariationByKey(projectConfig, flagKey, variationKey) + target := "flag (" + flagKey + ")" + if ruleKey != "" { + target += ", rule (" + ruleKey + ")" + } + + if err != nil { + decisionReasons.AddInfo("Invalid variation is mapped to %s and user (%s) in the forced decision map.", target, f.UserID) + return nil, decisionReasons, err + } + decisionReasons.AddInfo("Variation (%s) is mapped to %s and user (%s) in the forced decision map.", variationKey, target, f.UserID) + return _variation, decisionReasons, nil +} + +func (f *ForcedDecisionService) getFlagVariationByKey(projectConfig config.ProjectConfig, flagKey, variationKey string) (*entities.Variation, error) { + if variations, ok := projectConfig.GetFlagVariationsMap()[flagKey]; ok { + for _, variation := range variations { + if variation.Key == variationKey { + return &variation, nil + } + } + } + return nil, errors.New("variation not found") +} + +// CreateCopy creates and returns a copy of the forced decision service. +func (f *ForcedDecisionService) CreateCopy() *ForcedDecisionService { + f.mutex.RLock() + defer f.mutex.RUnlock() + forceDecisions := map[forcedDecision]string{} + for k, v := range f.forcedDecisions { + forceDecisions[k] = v + } + return &ForcedDecisionService{ + UserID: f.UserID, + forcedDecisions: forceDecisions, + mutex: new(sync.RWMutex), + } +} diff --git a/pkg/decision/forced_decision_service_test.go b/pkg/decision/forced_decision_service_test.go new file mode 100644 index 000000000..bed0e92c0 --- /dev/null +++ b/pkg/decision/forced_decision_service_test.go @@ -0,0 +1,222 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package decision // +package decision + +import ( + "fmt" + "io/ioutil" + "path/filepath" + "sync" + "testing" + + "github.com/optimizely/go-sdk/pkg/config" + "github.com/optimizely/go-sdk/pkg/decide" + "github.com/stretchr/testify/suite" +) + +var doOnce sync.Once // required since we only need to read datafile once +var datafile []byte + +type ForcedDecisionServiceTestSuite struct { + suite.Suite + forcedDecisionService *ForcedDecisionService + projectConfig config.ProjectConfig +} + +func (s *ForcedDecisionServiceTestSuite) SetupTest() { + s.forcedDecisionService = NewForcedDecisionService("abc") + doOnce.Do(func() { + absPath, _ := filepath.Abs("../../test-data/decide-test-datafile.json") + datafile, _ = ioutil.ReadFile(absPath) + }) + + configManager := config.NewStaticProjectConfigManagerWithOptions("", config.WithInitialDatafile(datafile)) + s.projectConfig, _ = configManager.GetConfig() +} + +func (s *ForcedDecisionServiceTestSuite) TestSetForcedDecision() { + s.True(s.forcedDecisionService.SetForcedDecision("1", "2", "3")) + s.True(s.forcedDecisionService.SetForcedDecision("1", "2", "")) + s.False(s.forcedDecisionService.SetForcedDecision("", "2", "3")) + s.True(s.forcedDecisionService.SetForcedDecision("1", "", "3")) + s.True(s.forcedDecisionService.SetForcedDecision("1", "", "")) + s.False(s.forcedDecisionService.SetForcedDecision("", "2", "")) + s.False(s.forcedDecisionService.SetForcedDecision("", "", "3")) + s.False(s.forcedDecisionService.SetForcedDecision("", "", "")) +} + +func (s *ForcedDecisionServiceTestSuite) TestGetForcedDecision() { + forcedDecision := s.forcedDecisionService.GetForcedDecision("1", "2") + s.Equal("", forcedDecision) + + s.True(s.forcedDecisionService.SetForcedDecision("1", "2", "3")) + forcedDecision = s.forcedDecisionService.GetForcedDecision("1", "2") + s.Equal("3", forcedDecision) + + s.True(s.forcedDecisionService.SetForcedDecision("1", "2", "")) + forcedDecision = s.forcedDecisionService.GetForcedDecision("1", "2") + s.Equal("", forcedDecision) + + s.False(s.forcedDecisionService.SetForcedDecision("", "2", "3")) + forcedDecision = s.forcedDecisionService.GetForcedDecision("", "2") + s.Equal("", forcedDecision) + + s.True(s.forcedDecisionService.SetForcedDecision("1", "", "3")) + forcedDecision = s.forcedDecisionService.GetForcedDecision("1", "") + s.Equal("3", forcedDecision) + + s.True(s.forcedDecisionService.SetForcedDecision("1", "", "")) + forcedDecision = s.forcedDecisionService.GetForcedDecision("1", "") + s.Equal("", forcedDecision) + + s.False(s.forcedDecisionService.SetForcedDecision("", "2", "")) + forcedDecision = s.forcedDecisionService.GetForcedDecision("", "2") + s.Equal("", forcedDecision) + + s.False(s.forcedDecisionService.SetForcedDecision("", "", "3")) + forcedDecision = s.forcedDecisionService.GetForcedDecision("", "") + s.Equal("", forcedDecision) + + s.False(s.forcedDecisionService.SetForcedDecision("", "", "")) + forcedDecision = s.forcedDecisionService.GetForcedDecision("", "") + s.Equal("", forcedDecision) +} + +func (s *ForcedDecisionServiceTestSuite) TestRemoveForcedDecision() { + forcedDecision := s.forcedDecisionService.GetForcedDecision("1", "2") + s.Equal("", forcedDecision) + + s.True(s.forcedDecisionService.SetForcedDecision("1", "2", "3")) + s.True(s.forcedDecisionService.RemoveForcedDecision("1", "2")) + s.Equal("", s.forcedDecisionService.GetForcedDecision("1", "2")) + + s.False(s.forcedDecisionService.SetForcedDecision("", "2", "3")) + s.False(s.forcedDecisionService.RemoveForcedDecision("", "2")) + s.Equal("", s.forcedDecisionService.GetForcedDecision("", "2")) + + s.True(s.forcedDecisionService.SetForcedDecision("1", "", "3")) + s.True(s.forcedDecisionService.RemoveForcedDecision("1", "")) + s.Equal("", s.forcedDecisionService.GetForcedDecision("1", "")) + + s.False(s.forcedDecisionService.SetForcedDecision("", "", "3")) + s.False(s.forcedDecisionService.RemoveForcedDecision("", "")) + s.Equal("", s.forcedDecisionService.GetForcedDecision("", "")) +} + +func (s *ForcedDecisionServiceTestSuite) TestRemoveAllForcedDecision() { + s.True(s.forcedDecisionService.SetForcedDecision("1", "a", "b")) + s.True(s.forcedDecisionService.SetForcedDecision("2", "", "b")) + s.False(s.forcedDecisionService.SetForcedDecision("", "a", "b")) + s.False(s.forcedDecisionService.SetForcedDecision("", "", "b")) + + s.Len(s.forcedDecisionService.forcedDecisions, 2) + s.True(s.forcedDecisionService.RemoveAllForcedDecisions()) + s.Len(s.forcedDecisionService.forcedDecisions, 0) + + s.Equal("", s.forcedDecisionService.GetForcedDecision("1", "a")) + s.Equal("", s.forcedDecisionService.GetForcedDecision("2", "")) +} + +func (s *ForcedDecisionServiceTestSuite) TestFindValidatedForcedDecision() { + s.True(s.forcedDecisionService.SetForcedDecision("feature_1", "", "a")) + variation, reasons, err := s.forcedDecisionService.FindValidatedForcedDecision(s.projectConfig, "feature_1", "", &decide.Options{IncludeReasons: true}) + s.NoError(err) + s.Len(reasons.ToReport(), 1) + s.Equal("Variation (a) is mapped to flag (feature_1) and user (abc) in the forced decision map.", reasons.ToReport()[0]) + s.Equal("a", variation.Key) + + s.True(s.forcedDecisionService.SetForcedDecision("feature_1", "exp_with_audience", "a")) + variation, reasons, err = s.forcedDecisionService.FindValidatedForcedDecision(s.projectConfig, "feature_1", "exp_with_audience", &decide.Options{IncludeReasons: true}) + s.NoError(err) + s.Len(reasons.ToReport(), 1) + s.Equal("Variation (a) is mapped to flag (feature_1), rule (exp_with_audience) and user (abc) in the forced decision map.", reasons.ToReport()[0]) + s.Equal("a", variation.Key) + + s.True(s.forcedDecisionService.SetForcedDecision("feature_2", "", "variation_with_traffic")) + variation, reasons, err = s.forcedDecisionService.FindValidatedForcedDecision(s.projectConfig, "feature_2", "", &decide.Options{IncludeReasons: true}) + s.NoError(err) + s.Len(reasons.ToReport(), 1) + s.Equal("Variation (variation_with_traffic) is mapped to flag (feature_2) and user (abc) in the forced decision map.", reasons.ToReport()[0]) + s.Equal("variation_with_traffic", variation.Key) + + s.True(s.forcedDecisionService.SetForcedDecision("feature_1", "", "fake")) + variation, reasons, err = s.forcedDecisionService.FindValidatedForcedDecision(s.projectConfig, "feature_1", "", &decide.Options{IncludeReasons: true}) + s.Error(err) + s.Len(reasons.ToReport(), 1) + s.Equal("Invalid variation is mapped to flag (feature_1) and user (abc) in the forced decision map.", reasons.ToReport()[0]) + s.Nil(variation) + + variation, reasons, err = s.forcedDecisionService.FindValidatedForcedDecision(s.projectConfig, "feature_3", "", &decide.Options{IncludeReasons: true}) + s.Error(err) + s.Len(reasons.ToReport(), 0) + s.Nil(variation) +} + +func (s *ForcedDecisionServiceTestSuite) TestCreateCopy() { + s.True(s.forcedDecisionService.SetForcedDecision("1", "2", "3")) + s.True(s.forcedDecisionService.SetForcedDecision("1", "2", "")) + + ucCopy := s.forcedDecisionService.CreateCopy() + s.Equal(len(s.forcedDecisionService.forcedDecisions), len(ucCopy.forcedDecisions)) + + ucCopy.RemoveAllForcedDecisions() + s.NotEqual(len(s.forcedDecisionService.forcedDecisions), len(ucCopy.forcedDecisions)) +} + +func (s *ForcedDecisionServiceTestSuite) TestAsyncBehaviour() { + var wg sync.WaitGroup + wg.Add(2) + + setForcedDecisions := func() { + i := 0 + for i < 100 { + s.forcedDecisionService.SetForcedDecision(fmt.Sprint(i), "b", "c") + i++ + } + wg.Done() + } + + getForcedDecisions := func() { + i := 0 + for i < 100 { + s.forcedDecisionService.GetForcedDecision(fmt.Sprint(i), "b") + i++ + } + wg.Done() + } + + removeAllForcedDecisions := func() { + s.forcedDecisionService.RemoveAllForcedDecisions() + wg.Done() + } + + go setForcedDecisions() + go getForcedDecisions() + wg.Wait() + s.Len(s.forcedDecisionService.forcedDecisions, 100) + + wg.Add(2) + go getForcedDecisions() + go removeAllForcedDecisions() + wg.Wait() + s.Len(s.forcedDecisionService.forcedDecisions, 0) +} + +func TestForcedDecisionServiceTestSuite(t *testing.T) { + suite.Run(t, new(ForcedDecisionServiceTestSuite)) +} diff --git a/pkg/decision/helpers_test.go b/pkg/decision/helpers_test.go index a0f7253d0..70cc44419 100644 --- a/pkg/decision/helpers_test.go +++ b/pkg/decision/helpers_test.go @@ -54,6 +54,11 @@ func (c *mockProjectConfig) GetAudienceMap() map[string]entities.Audience { return args.Get(0).(map[string]entities.Audience) } +func (c *mockProjectConfig) GetFlagVariationsMap() map[string][]entities.Variation { + args := c.Called() + return args.Get(0).(map[string][]entities.Variation) +} + type MockExperimentDecisionService struct { mock.Mock } @@ -98,6 +103,10 @@ func (m *MockAudienceTreeEvaluator) Evaluate(node *entities.TreeNode, condTreePa // Single variation experiment const testExp1111Key = "test_experiment_1111" +const testExp1112Key = "test_experiment_1112" +const testExp1117Key = "test_experiment_1117" +const testExp1118Key = "test_experiment_1118" + var testExp1111Var2222 = entities.Variation{ID: "2222", Key: "2222"} var testExp1111 = entities.Experiment{ ID: "1111", @@ -140,7 +149,7 @@ var testExp1112 = entities.Experiment{ }, }, ID: "1112", - Key: testExp1111Key, + Key: testExp1112Key, Variations: map[string]entities.Variation{ "2222": testExp1111Var2222, }, @@ -161,7 +170,7 @@ var testExp1117 = entities.Experiment{ }, }, ID: "1117", - Key: testExp1111Key, + Key: testExp1117Key, Variations: map[string]entities.Variation{ "2223": testExp1117Var2223, }, @@ -182,7 +191,7 @@ var testExp1118 = entities.Experiment{ }, }, ID: "1118", - Key: testExp1111Key, + Key: testExp1118Key, Variations: map[string]entities.Variation{ "2224": testExp1118Var2224, }, diff --git a/pkg/decision/interface.go b/pkg/decision/interface.go index ff23b355d..ec5a4cadc 100644 --- a/pkg/decision/interface.go +++ b/pkg/decision/interface.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -25,8 +25,8 @@ import ( // Service interface is used to make a decision for a given feature or experiment type Service interface { - GetFeatureDecision(FeatureDecisionContext, entities.UserContext, *decide.Options) (FeatureDecision, decide.DecisionReasons, error) GetExperimentDecision(ExperimentDecisionContext, entities.UserContext, *decide.Options) (ExperimentDecision, decide.DecisionReasons, error) + GetFeatureDecision(FeatureDecisionContext, entities.UserContext, *decide.Options) (FeatureDecision, decide.DecisionReasons, error) OnDecision(func(notification.DecisionNotification)) (int, error) RemoveOnDecision(id int) error } diff --git a/pkg/decision/reasons/reason.go b/pkg/decision/reasons/reason.go index 4674cc321..4814fb69b 100644 --- a/pkg/decision/reasons/reason.go +++ b/pkg/decision/reasons/reason.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020, Optimizely, Inc. and contributors * + * Copyright 2019-2021, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -41,6 +41,8 @@ const ( NoRolloutForFeature Reason = "No rollout for feature" // RolloutHasNoExperiments - the rollout has no assigned experiments RolloutHasNoExperiments Reason = "Rollout has no experiments" + // ForcedDecisionFound - forced decision was found for provided flag and ruleKey against the user + ForcedDecisionFound Reason = "Forced decision found" // NotBucketedIntoVariation - the user is not bucketed into a variation for the given experiment NotBucketedIntoVariation Reason = "Not bucketed into a variation" // NotInGroup - the user is not bucketed into the mutex group diff --git a/pkg/decision/rollout_service.go b/pkg/decision/rollout_service.go index e74f2668f..b27296571 100644 --- a/pkg/decision/rollout_service.go +++ b/pkg/decision/rollout_service.go @@ -65,25 +65,6 @@ func (r RolloutService) GetDecision(decisionContext FeatureDecisionContext, user return evalResult } - getFeatureDecision := func(experiment *entities.Experiment, decision *ExperimentDecision) FeatureDecision { - // translate the experiment reason into a more rollouts-appropriate reason - switch decision.Reason { - case pkgReasons.NotBucketedIntoVariation: - featureDecision.Decision = Decision{Reason: pkgReasons.FailedRolloutBucketing} - case pkgReasons.BucketedIntoVariation: - featureDecision.Decision = Decision{Reason: pkgReasons.BucketedIntoRollout} - default: - featureDecision.Decision = decision.Decision - } - - featureDecision.Variation = decision.Variation - if featureDecision.Variation != nil { - featureDecision.Experiment = *experiment - } - r.logger.Debug(fmt.Sprintf(`Decision made for user "%s" for feature rollout with key "%s": %s.`, userContext.ID, feature.Key, featureDecision.Reason)) - return featureDecision - } - getExperimentDecisionContext := func(experiment *entities.Experiment) ExperimentDecisionContext { return ExperimentDecisionContext{ Experiment: experiment, @@ -103,9 +84,29 @@ func (r RolloutService) GetDecision(decisionContext FeatureDecisionContext, user return featureDecision, reasons, nil } + checkForForcedDecision := func(exp *entities.Experiment) *FeatureDecision { + forcedDecision, _reasons := r.getForcedDecision(decisionContext, *exp, options) + reasons.Append(_reasons) + if forcedDecision != nil { + experimentDecision := &ExperimentDecision{ + Variation: forcedDecision, + Decision: Decision{Reason: pkgReasons.ForcedDecisionFound}, + } + decision := r.getFeatureDecision(&featureDecision, userContext, *feature, exp, experimentDecision) + return &decision + } + return nil + } + for index := 0; index < numberOfExperiments-1; index++ { loggingKey := strconv.Itoa(index + 1) experiment := &rollout.Experiments[index] + + // Checking for forced decision + if forcedDecision := checkForForcedDecision(experiment); forcedDecision != nil { + return *forcedDecision, reasons, nil + } + experimentDecisionContext := getExperimentDecisionContext(experiment) // Move to next evaluation if condition tree is available and evaluation fails @@ -124,12 +125,18 @@ func (r RolloutService) GetDecision(decisionContext FeatureDecisionContext, user // Evaluate fall back rule / last rule now break } - finalFeatureDecision := getFeatureDecision(experiment, &decision) + finalFeatureDecision := r.getFeatureDecision(&featureDecision, userContext, *feature, experiment, &decision) return finalFeatureDecision, reasons, nil } // fall back rule / last rule experiment := &rollout.Experiments[numberOfExperiments-1] + + // Checking for forced decision + if forcedDecision := checkForForcedDecision(experiment); forcedDecision != nil { + return *forcedDecision, reasons, nil + } + experimentDecisionContext := getExperimentDecisionContext(experiment) // Move to bucketing if conditionTree is unavailable or evaluation passes evaluationResult := experiment.AudienceConditionTree == nil || evaluateConditionTree(experiment, "Everyone Else") @@ -142,9 +149,41 @@ func (r RolloutService) GetDecision(decisionContext FeatureDecisionContext, user logMessage := reasons.AddInfo(logging.UserInEveryoneElse.String(), userContext.ID) r.logger.Debug(logMessage) } - finalFeatureDecision := getFeatureDecision(experiment, &decision) + finalFeatureDecision := r.getFeatureDecision(&featureDecision, userContext, *feature, experiment, &decision) return finalFeatureDecision, reasons, nil } return featureDecision, reasons, nil } + +// creating this sub method to avoid cyco-complexity warning +func (r RolloutService) getFeatureDecision(featureDecision *FeatureDecision, userContext entities.UserContext, feature entities.Feature, experiment *entities.Experiment, decision *ExperimentDecision) FeatureDecision { + // translate the experiment reason into a more rollouts-appropriate reason + switch decision.Reason { + case pkgReasons.NotBucketedIntoVariation: + featureDecision.Decision = Decision{Reason: pkgReasons.FailedRolloutBucketing} + case pkgReasons.BucketedIntoVariation: + featureDecision.Decision = Decision{Reason: pkgReasons.BucketedIntoRollout} + default: + featureDecision.Decision = decision.Decision + } + + featureDecision.Variation = decision.Variation + if featureDecision.Variation != nil { + featureDecision.Experiment = *experiment + } + r.logger.Debug(fmt.Sprintf(`Decision made for user "%s" for feature rollout with key "%s": %s.`, userContext.ID, feature.Key, featureDecision.Reason)) + return *featureDecision +} + +func (r RolloutService) getForcedDecision(decisionContext FeatureDecisionContext, experiment entities.Experiment, options *decide.Options) (variation *entities.Variation, reasons decide.DecisionReasons) { + reasons = decide.NewDecisionReasons(options) + if decisionContext.ForcedDecisionService != nil { + forcedDecision, _reasons, err := decisionContext.ForcedDecisionService.FindValidatedForcedDecision(decisionContext.ProjectConfig, decisionContext.Feature.Key, experiment.Key, options) + reasons.Append(_reasons) + if err == nil { + return forcedDecision, reasons + } + } + return nil, reasons +} diff --git a/pkg/decision/rollout_service_test.go b/pkg/decision/rollout_service_test.go index 17dcf0bd8..13f412650 100644 --- a/pkg/decision/rollout_service_test.go +++ b/pkg/decision/rollout_service_test.go @@ -54,8 +54,9 @@ func (s *RolloutServiceTestSuite) SetupTest() { ProjectConfig: s.mockConfig, } s.testFeatureDecisionContext = FeatureDecisionContext{ - Feature: &testFeatRollout3334, - ProjectConfig: s.mockConfig, + Feature: &testFeatRollout3334, + ProjectConfig: s.mockConfig, + ForcedDecisionService: NewForcedDecisionService("test_user"), } testAudienceMap := map[string]entities.Audience{ @@ -146,6 +147,34 @@ func (s *RolloutServiceTestSuite) TestGetDecisionHappyPath() { s.mockLogger.AssertExpectations(s.T()) } +func (s *RolloutServiceTestSuite) TestGetDecisionHappyPathWithForcedDecision() { + testRolloutService := RolloutService{ + audienceTreeEvaluator: s.mockAudienceTreeEvaluator, + experimentBucketerService: s.mockExperimentService, + logger: s.mockLogger, + } + expectedFeatureDecision := FeatureDecision{ + Experiment: testExp1112, + Variation: &testExp1112Var2222, + Source: Rollout, + Decision: Decision{Reason: reasons.ForcedDecisionFound}, + } + + flagVariationsMap := map[string][]entities.Variation{ + s.testFeatureDecisionContext.Feature.Key: { + testExp1112Var2222, + }, + } + s.options.IncludeReasons = true + s.mockConfig.On("GetFlagVariationsMap").Return(flagVariationsMap) + s.mockLogger.On("Debug", fmt.Sprintf(logging.EvaluatingAudiencesForRollout.String(), "1")) + s.mockLogger.On("Debug", `Decision made for user "test_user" for feature rollout with key "test_feature_rollout_3334_key": Forced decision found.`) + s.testFeatureDecisionContext.ForcedDecisionService.SetForcedDecision(s.testFeatureDecisionContext.Feature.Key, testExp1112.Key, testExp1112Var2222.Key) + decision, rsons, _ := testRolloutService.GetDecision(s.testFeatureDecisionContext, s.testUserContext, s.options) + s.Equal(expectedFeatureDecision, decision) + s.Equal("Variation (2222) is mapped to flag (test_feature_rollout_3334_key), rule (test_experiment_1112) and user (test_user) in the forced decision map.", rsons.ToReport()[0]) +} + func (s *RolloutServiceTestSuite) TestGetDecisionFallbacksToLastWhenFailsBucketing() { testExperiment1112BucketerDecision := ExperimentDecision{ Decision: Decision{ @@ -194,6 +223,53 @@ func (s *RolloutServiceTestSuite) TestGetDecisionFallbacksToLastWhenFailsBucketi s.mockLogger.AssertExpectations(s.T()) } +func (s *RolloutServiceTestSuite) TestFallbackRuleWithForcedDecision() { + testExperiment1112BucketerDecision := ExperimentDecision{ + Decision: Decision{ + Reason: reasons.NotBucketedIntoVariation, + }, + } + s.mockAudienceTreeEvaluator.On("Evaluate", testExp1112.AudienceConditionTree, s.testConditionTreeParams, mock.Anything).Return(true, true, s.reasons) + s.mockExperimentService.On("GetDecision", s.testExperiment1112DecisionContext, s.testUserContext, s.options, mock.Anything).Return(testExperiment1112BucketerDecision, s.reasons, nil) + + testRolloutService := RolloutService{ + audienceTreeEvaluator: s.mockAudienceTreeEvaluator, + experimentBucketerService: s.mockExperimentService, + logger: s.mockLogger, + } + expectedFeatureDecision := FeatureDecision{ + Experiment: testExp1118, + Variation: &testExp1118Var2224, + Source: Rollout, + Decision: Decision{Reason: reasons.ForcedDecisionFound}, + } + flagVariationsMap := map[string][]entities.Variation{ + s.testFeatureDecisionContext.Feature.Key: { + testExp1118Var2224, + }, + } + s.mockConfig.On("GetFlagVariationsMap").Return(flagVariationsMap) + + // Adding invalid forced decision to verify reasons + s.testFeatureDecisionContext.ForcedDecisionService.SetForcedDecision(s.testFeatureDecisionContext.Feature.Key, testExp1112.Key, "invalid") + s.testFeatureDecisionContext.ForcedDecisionService.SetForcedDecision(s.testFeatureDecisionContext.Feature.Key, testExp1118.Key, testExp1118Var2224.Key) + + s.mockLogger.On("Debug", fmt.Sprintf(logging.EvaluatingAudiencesForRollout.String(), "1")) + s.mockLogger.On("Debug", fmt.Sprintf(logging.RolloutAudiencesEvaluatedTo.String(), "1", true)) + s.mockLogger.On("Debug", fmt.Sprintf(logging.EvaluatingAudiencesForRollout.String(), "Everyone Else")) + s.mockLogger.On("Debug", fmt.Sprintf(logging.RolloutAudiencesEvaluatedTo.String(), "Everyone Else", true)) + s.mockLogger.On("Debug", fmt.Sprintf(logging.UserInEveryoneElse.String(), "test_user")) + s.mockLogger.On("Debug", `Decision made for user "test_user" for feature rollout with key "test_feature_rollout_3334_key": Forced decision found.`) + s.options.IncludeReasons = true + decision, rsons, _ := testRolloutService.GetDecision(s.testFeatureDecisionContext, s.testUserContext, s.options) + messages := rsons.ToReport() + s.Len(messages, 2) + s.Equal(`Invalid variation is mapped to flag (test_feature_rollout_3334_key), rule (test_experiment_1112) and user (test_user) in the forced decision map.`, messages[0]) + s.Equal(`Variation (2224) is mapped to flag (test_feature_rollout_3334_key), rule (test_experiment_1118) and user (test_user) in the forced decision map.`, messages[1]) + + s.Equal(expectedFeatureDecision, decision) +} + func (s *RolloutServiceTestSuite) TestGetDecisionWhenFallbackBucketingFails() { testExperiment1112BucketerDecision := ExperimentDecision{ Decision: Decision{