Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ups): Added UserProfileService Support. #326

Merged
merged 26 commits into from
Feb 11, 2022
Merged

Conversation

yasirfolio3
Copy link
Contributor

@yasirfolio3 yasirfolio3 commented Dec 24, 2021

Summary

  • Provided 2 out of the box user profile services including in-memory, rest and redis.
  • Added lifo and fifo ordering in in-memory UPS.
  • Added support for custom user profile services

Build Size

  • Without UPS: 12.1 mb
  • With UPS: 12.7 mb

Build Time

  • Without UPS: 7.384s
  • With UPS: 7.387s

Performance

  • Without UPS: 1000 decide all calls in 580ms
  • With UPS (disabled by default): 1000 decide all calls in 580ms

UserProfileService EndPoints

  1. To lookup a user profile, use agent's POST /v1/lookup:
In the request `application/json` body, include the `userId`. The full request looks like this:

```curl
curl --location --request POST 'http://localhost:8080/v1/lookup' \
--header 'X-Optimizely-SDK-Key: YOUR_SDK_KEY' \
--header 'Accept: text/event-stream' \
--header 'Content-Type: application/json' \
--data-raw '{
  "userId": "string"
}'
  1. To save a user profile, use agent's POST /v1/save:
In the request `application/json` body, include the `userId` and `experimentBucketMap`. The full request looks like this:

```curl
curl --location --request POST 'http://localhost:8080/v1/save' \
--header 'X-Optimizely-SDK-Key: YOUR_SDK_KEY' \
--header 'Accept: text/event-stream' \
--header 'Content-Type: application/json' \
--data-raw '{
    "userId": "string",
    "experimentBucketMap": {
        "experiment_id_to_save": {
            "variation_id": "variation_id_to_save"
        }
    }
}'

Out of Box UserProfileService Usage

  1. To use the in-memory UserProfileService, update the config.yaml as shown below:
## configure optional User profile service
userProfileService:
      default: "in-memory"
      services:
        in-memory: 
          ## 0 means no limit on capacity
          capacity: 0
          ## supports lifo/fifo
          storageStrategy: "fifo"
  1. To use the redis UserProfileService, update the config.yaml as shown below:
## configure optional User profile service
userProfileService:
      default: "redis"
      services:
        redis: 
          host: "your_host"
          password: "your_password"
          database: 0 ## your database
  1. To use the rest UserProfileService, update the config.yaml as shown below:
## configure optional User profile service
userProfileService:
      default: "rest"
      services:
        rest:
          host: "your_host"
          lookupPath: "/lookup_endpoint"
          lookupMethod: "POST"
          savePath: "/save_endpoint"
          saveMethod: "POST"
          userIDKey: "user_id"
          headers: 
            "header_key": "header_value"

Implement 2 POST api's /lookup_endpoint and /save_endpoint on your host. Api methods will be POST by default but can be updated through lookupMethod and saveMethod properties. Similarly, request parameter key for user_id can also be updated using userIDKey property.

  • lookup_endpoint should accept user_id in its json body or query (depending upon the method type) and if successful, return the status code 200 with json response (keep in mind that when sending response, user_id should be substituted with value of userIDKey from config.yaml):
{
  "experiment_bucket_map": {
    "saved_experiment_id": {
      "variation_id": "saved_variation_id"
    }
  },
  "user_id": "saved_user_id"
}
  • save_endpoint should accept the following parameters in its json body or query (depending upon the method type) and return the status code 200 if successful (keep in mind that user_id should be substituted with the value ofuserIDKey from config.yaml):
{
  "experiment_bucket_map": {
    "experiment_id_to_save": {
      "variation_id": "variation_id_to_save"
    }
  },
  "user_id": "user_id_to_save"
}

Custom UserProfileService Implementation

To implement a custom user profile service, followings steps need to be taken:

  1. Create a struct that implements the decision.UserProfileService interface in plugins/userprofileservice/services.
  2. Add a init method inside your UserProfileService file as shown below:
func init() {
	myUPSCreator := func() decision.UserProfileService {
		return &yourUserProfileServiceStruct{
		}
	}
	userprofileservice.Add("my_ups_name", myUPSCreator)
}
  1. Update the config.yaml file with your UserProfileService config as shown below:
## configure optional User profile service
userProfileServices:
    default: "my_ups_name"
    services:
        my_ups_name: 
           ## Add those parameters here that need to be mapped to the UserProfileService
           ## For example, if the UPS struct has a json mappable property called `host`
           ## it can updated with value `abc.com` as shown
           host: “abc.com”
  • If a user has created multiple UserProfileServices and wants to override the default UserProfileService for a specifc sdkKey, they can do so by providing the UserProfileService name in the request Header X-Optimizely-UPS-Name.

Tests

  • Unit tests added.
  • FSC should pass.

@@ -114,6 +119,11 @@ func (c *OptlyCache) UpdateConfigs(sdkKey string) {
}
}

// SetUserProfileService sets maps userProfileService against the given sdkKey
func (c *OptlyCache) SetUserProfileService(sdkKey, userProfileService string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add lock here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are using userProfileServiceMap cmap.ConcurrentMap here which handles concurrency itself.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should still add the mutex here. For reading the ConcurrentMap should be fine but when setting, I believe the mutex should be used to ensure no race condition, even with the ConcurrentMap, just to be safe.. I could be wrong though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will need to check this part again.

@yasirfolio3 yasirfolio3 marked this pull request as ready for review January 5, 2022 07:45
@anvth
Copy link

anvth commented Jan 6, 2022

Hey team, this is amazing news and something my team was considering contributing! 💟

Just a question, in the example config we see there is an option to specify custom URL for User Profile Service. However, reviewing PR I could not see an implementation for that. Is there a plan to support that any time soon? Even the PR description does not cover this.

Thank you :-)

@yasirfolio3
Copy link
Contributor Author

Hey team, this is amazing news and something my team was considering contributing! 💟

Just a question, in the example config we see there is an option to specify custom URL for User Profile Service. However, reviewing PR I could not see an implementation for that. Is there a plan to support that any time soon? Even the PR description does not cover this.

Thank you :-)

Hi @anvth , We have updated the PR description, please have a look and let us know if there's anything else we can help with, Thanks.

Copy link

@The-inside-man The-inside-man left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Just the one comment around mutex as @msohailhussain suggested.

@204Constie
Copy link

Hey team, this is amazing news for us as we were planning to implement this. 😸
A significant question here that we would have would be about the custom UPS implementation. Contrary to how in memory and redis are implemented there is no way of implementing it by simply providing certain options in the config file.
Did you consider an option to make it less flexible but not requiring changes in the code ?
What about having an out of the box option for microservice UPS ?

thank you 😸

Copy link
Contributor

@jaeopt jaeopt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a suggestion and a clarification.
Other than those, it looks great to me as far as I can follow with go :)

Comment on lines +104 to +108
case "lifo":
u.lifoOrderedProfiles = append(u.lifoOrderedProfiles, profile.ID)
default:
// fifo by default
u.fifoOrderedProfiles <- profile.ID
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The benefit of supporting fifo/lifo is not clear to me. Can we just implement the default LRU cache unless we see clear reasons they prefer fifo/lifo?

// Lookup is used to retrieve past bucketing decisions for users
func (r *RestUserProfileService) Lookup(userID string) (profile decision.UserProfile) {
if userID == "" {
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it ok not returning profile in go?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, if we don't do it here, it will just return empty profile struct.

@yasirfolio3 yasirfolio3 reopened this Jan 24, 2022
lookupMethod, saveMethod, userIDKey
@204Constie
Copy link

hello! 😸 all of the updates to rest UPS look wonderful 😻 all configurable via config.yml file 😻 great work!
i have a small question. i can read that both lookup and save methods expect the rest endpoints to return the profile on success. this is for sure necessary in case of lookup but is it really required in case of save method ? does it use the response of the save method or expect to use it in the future ? or it actually only require the response of status code 2XX and would in truth behave the same in case of 200 and 204 ?

@yasirfolio3
Copy link
Contributor Author

hello! 😸 all of the updates to rest UPS look wonderful 😻 all configurable via config.yml file 😻 great work! i have a small question. i can read that both lookup and save methods expect the rest endpoints to return the profile on success. this is for sure necessary in case of lookup but is it really required in case of save method ? does it use the response of the save method or expect to use it in the future ? or it actually only require the response of status code 2XX and would in truth behave the same in case of 200 and 204 ?

Hi @204Constie , In case of save, only 200 status code needs to be sent if successful (204 is not supported). The documentation is actually stating the payload required for the save request 👍

@204Constie
Copy link

hello! 😸 all of the updates to rest UPS look wonderful 😻 all configurable via config.yml file 😻 great work! i have a small question. i can read that both lookup and save methods expect the rest endpoints to return the profile on success. this is for sure necessary in case of lookup but is it really required in case of save method ? does it use the response of the save method or expect to use it in the future ? or it actually only require the response of status code 2XX and would in truth behave the same in case of 200 and 204 ?

Hi @204Constie , In case of save, only 200 status code needs to be sent if successful (204 is not supported). The documentation is actually stating the payload required for the save request 👍

😸 this is perfectly fine, i was just wondering why is this the case. is the response used in any way ? is there some other reason ? i'm simply curious

@yasirfolio3
Copy link
Contributor Author

hello! 😸 all of the updates to rest UPS look wonderful 😻 all configurable via config.yml file 😻 great work! i have a small question. i can read that both lookup and save methods expect the rest endpoints to return the profile on success. this is for sure necessary in case of lookup but is it really required in case of save method ? does it use the response of the save method or expect to use it in the future ? or it actually only require the response of status code 2XX and would in truth behave the same in case of 200 and 204 ?

Hi @204Constie , In case of save, only 200 status code needs to be sent if successful (204 is not supported). The documentation is actually stating the payload required for the save request 👍

😸 this is perfectly fine, i was just wondering why is this the case. is the response used in any way ? is there some other reason ? i'm simply curious

So for save API, no response is required. Even if a response is sent, it will be ignored by agent. Agent only checks for status code 200.

Copy link
Contributor

@msohailhussain msohailhussain left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please address

.travis.yml Outdated
@@ -8,6 +8,9 @@ env:
# may also want to run `go mod edit -go=1.13` to fix go.mod as well
- GIMME_GO_VERSION=1.13.x GIMME_OS=linux GIMME_ARCH=amd64

services:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the purpose of this service, please add as a comment.

@@ -95,6 +95,7 @@ Below is a comprehensive list of available configuration properties.
|client.pollingInterval|OPTIMIZELY_CLIENT_POLLINGINTERVAL|The time between successive polls for updated project configuration. Default: 1m|
|client.queueSize|OPTIMIZELY_CLIENT_QUEUESIZE|The max number of events pending dispatch. Default: 1000|
|client.sdkKeyRegex|OPTIMIZELY_CLIENT_SDKKEYREGEX|Regex to validate SDK keys provided in request header. Default: ^\\w+(:\\w+)?$|
|client.userProfileService|OPTIMIZELY_CLIENT_USERPROFILESERVICE| Property used to enable and set UserProfileServices. Default: ./config.yaml|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this needs to be revised.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -84,6 +87,11 @@ func loadConfig(v *viper.Viper) *config.AgentConfig {
conf.Server.Interceptors = interceptors
}

// Check if JSON string was set using OPTIMIZELY_CLIENT_USERPROFILESERVICE
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OPTIMIZELY_CLIENT_USERPROFILESERVICE what does it mean?

@@ -78,6 +78,10 @@ func NewDefaultConfig() *AgentConfig {
EventURL: "https://logx.optimizely.com/v1/events",
// https://github.com/google/re2/wiki/Syntax
SdkKeyRegex: "^\\w+(:\\w+)?$",
UserProfileService: UserProfileServiceConfigs{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

space before curly braces.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested by linter, Golang follows the same convention for initialising new properties.

github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so many packages are added, we will need to check if we can ship without redis.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have updated the PR description with build size with redis.


createAndReturnUPSWithName := func(upsName string) decision.UserProfileService {
if clientConfigUPSMap, ok := conf.UserProfileService["services"].(map[string]interface{}); ok {
if defaultUserProfileServiceMap, ok := clientConfigUPSMap[upsName].(map[string]interface{}); ok {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userprofileserviceconfig should be better name.

return nil
}

// Check if ups type was provided in the request headers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name

// Check if ups type was provided in the request headers
if ups, ok := userProfileServiceMap.Get(sdkKey); ok {
if upsNameStr, ok := ups.(string); ok && upsNameStr != "" {
return createAndReturnUPSWithName(upsNameStr)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initialize or New

@@ -25,6 +25,7 @@ import (
type Cache interface {
GetClient(sdkKey string) (*OptlyClient, error)
UpdateConfigs(sdkKey string)
SetUserProfileService(sdkKey, userProfileService string)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add description, why we need to add here.

fifoOrderedProfiles chan string
lifoOrderedProfiles []string
lock sync.RWMutex
isReady bool
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why we need isReady?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we need to initialize fifoOrderedProfiles with capacity and this can only be done when first save is called, isReady is used to this purpose. All other required initialisations also take place if isReady is false.

u.fifoOrderedProfiles = make(chan string, u.Capacity)

Copy link
Contributor

@msohailhussain msohailhussain left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Copy link
Contributor

@jaeopt jaeopt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@msohailhussain msohailhussain merged commit 5da4ab3 into master Feb 11, 2022
@msohailhussain msohailhussain deleted the yasir/inmemory-ups branch February 11, 2022 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants