From 5187d1de76490fd62cfaef5196b8dec2a1fca5a9 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Fri, 20 Jun 2025 11:46:11 -0700 Subject: [PATCH 1/4] add cmab config --- README.md | 53 ++++++++- cmd/optimizely/main.go | 36 +++++- cmd/optimizely/main_test.go | 231 +++++++++++++++++++++++++++++++++++- config.yaml | 34 ++++++ config/config.go | 43 ++++++- config/config_test.go | 52 +++++++- 6 files changed, 442 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5961a26b..187a468c 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,10 @@ Below is a comprehensive list of available configuration properties. | log.level | OPTIMIZELY_LOG_LEVEL | The log [level](https://github.com/rs/zerolog#leveled-logging) for the agent. Default: info | | log.pretty | OPTIMIZELY_LOG_PRETTY | Flag used to set colorized console output as opposed to structured json logs. Default: false | | name | OPTIMIZELY_NAME | Agent name. Default: optimizely | -| sdkKeys | OPTIMIZELY_SDKKEYS | Comma delimited list of SDK keys used to initialize on startup | +| sdkKeys | OPTIMIZELY_SDKKEYS | Comma delimited list of SDK keys used to initialize on startup | +| cmab | OPTIMIZELY_CMAB | Complete JSON configuration for CMAB. Format: see example below | +| cmab.cache | OPTIMIZELY_CMAB_CACHE | JSON configuration for just the CMAB cache section. Format: see example below | +| cmab.retryConfig | OPTIMIZELY_CMAB_RETRYCONFIG | JSON configuration for just the CMAB retry settings. Format: see example below | | server.allowedHosts | OPTIMIZELY_SERVER_ALLOWEDHOSTS | List of allowed request host values. Requests whose host value does not match either the configured server.host, or one of these, will be rejected with a 404 response. To match all subdomains, you can use a leading dot (for example `.example.com` matches `my.example.com`, `hello.world.example.com`, etc.). You can use the value `.` to disable allowed host checking, allowing requests with any host. Request host is determined in the following priority order: 1. X-Forwarded-Host header value, 2. Forwarded header host= directive value, 3. Host property of request (see Host under https://pkg.go.dev/net/http#Request). Note: don't include port in these hosts values - port is stripped from the request host before comparing against these. | | server.batchRequests.maxConcurrency | OPTIMIZELY_SERVER_BATCHREQUESTS_MAXCONCURRENCY | Number of requests running in parallel. Default: 10 | | server.batchRequests.operationsLimit | OPTIMIZELY_SERVER_BATCHREQUESTS_OPERATIONSLIMIT | Number of allowed operations. ( will flag an error if the number of operations exeeds this parameter) Default: 500 | @@ -142,6 +145,54 @@ Below is a comprehensive list of available configuration properties. | webhook.projects.<_projectId_>.secret | N/A | Webhook secret used to validate webhook requests originating from the respective projectId | | webhook.projects.<_projectId_>.skipSignatureCheck | N/A | Boolean to indicate whether the signature should be validated. TODO remove in favor of empty secret. | +### CMAB Configuration Examples + +**Complete CMAB Configuration (OPTIMIZELY_CMAB)**: +```json +{ + "enabled": true, + "predictionEndpoint": "https://custom-endpoint.com", + "requestTimeout": "5s", + "cache": { + "type": "redis", + "size": 2000, + "ttl": "45m", + "redis": { + "host": "redis:6379", + "password": "", + "database": 0 + } + }, + "retryConfig": { + "maxRetries": 3, + "initialBackoff": "100ms", + "maxBackoff": "10s", + "backoffMultiplier": 2.0 + } +} + +CMAB Cache Configuration (OPTIMIZELY_CMAB_CACHE): + +{ + "type": "redis", + "size": 2000, + "ttl": "45m", + "redis": { + "host": "redis:6379", + "password": "", + "database": 0 + } +} + +CMAB Retry Configuration (OPTIMIZELY_CMAB_RETRYCONFIG): + +{ + "maxRetries": 3, + "initialBackoff": "100ms", + "maxBackoff": "10s", + "backoffMultiplier": 2.0 +} + More information about configuring Agent can be found in the [Advanced Configuration Notes](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/advanced-configuration). ### API diff --git a/cmd/optimizely/main.go b/cmd/optimizely/main.go index 5784a5d1..b7ad6976 100644 --- a/cmd/optimizely/main.go +++ b/cmd/optimizely/main.go @@ -25,6 +25,7 @@ import ( "runtime" "strings" "syscall" + "time" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -98,15 +99,46 @@ func loadConfig(v *viper.Viper) *config.AgentConfig { } // Check if JSON string was set using OPTIMIZELY_CLIENT_USERPROFILESERVICE environment variable - if userProfileService := v.GetStringMap("client.userprofileservice"); userProfileService != nil { + if userProfileService := v.GetStringMap("client.userprofileservice"); len(userProfileService) > 0 { conf.Client.UserProfileService = userProfileService } // Check if JSON string was set using OPTIMIZELY_CLIENT_ODP_SEGMENTSCACHE environment variable - if odpSegmentsCache := v.GetStringMap("client.odp.segmentsCache"); odpSegmentsCache != nil { + if odpSegmentsCache := v.GetStringMap("client.odp.segmentsCache"); len(odpSegmentsCache) > 0 { conf.Client.ODP.SegmentsCache = odpSegmentsCache } + // Handle CMAB configuration using the same approach as UserProfileService + // Check for complete CMAB configuration first + if cmab := v.GetStringMap("cmab"); len(cmab) > 0 { + if enabled, ok := cmab["enabled"].(bool); ok { + conf.CMAB.Enabled = enabled + } + if endpoint, ok := cmab["predictionEndpoint"].(string); ok { + conf.CMAB.PredictionEndpoint = endpoint + } + if timeout, ok := cmab["requestTimeout"].(string); ok { + if duration, err := time.ParseDuration(timeout); err == nil { + conf.CMAB.RequestTimeout = duration + } + } + if cache, ok := cmab["cache"].(map[string]interface{}); ok { + conf.CMAB.Cache = cache + } + if retryConfig, ok := cmab["retryConfig"].(map[string]interface{}); ok { + conf.CMAB.RetryConfig = retryConfig + } + } + + // Check for individual map sections + if cmabCache := v.GetStringMap("cmab.cache"); len(cmabCache) > 0 { + conf.CMAB.Cache = cmabCache + } + + if cmabRetryConfig := v.GetStringMap("cmab.retryConfig"); len(cmabRetryConfig) > 0 { + conf.CMAB.RetryConfig = cmabRetryConfig + } + return conf } diff --git a/cmd/optimizely/main_test.go b/cmd/optimizely/main_test.go index 72ae36fa..42ea8d3b 100644 --- a/cmd/optimizely/main_test.go +++ b/cmd/optimizely/main_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020,2022-2023, Optimizely, Inc. and contributors * + * Copyright 2019-2020,2022-2025, 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. * @@ -17,7 +17,9 @@ package main import ( + "fmt" "os" + "strings" "testing" "time" @@ -178,6 +180,168 @@ func assertWebhook(t *testing.T, actual config.WebhookConfig) { assert.False(t, actual.Projects[20000].SkipSignatureCheck) } +func assertCMAB(t *testing.T, cmab config.CMABConfig) { + fmt.Println("In assertCMAB, received CMAB config:") + fmt.Printf(" Enabled: %v\n", cmab.Enabled) + fmt.Printf(" PredictionEndpoint: %s\n", cmab.PredictionEndpoint) + fmt.Printf(" RequestTimeout: %v\n", cmab.RequestTimeout) + fmt.Printf(" Cache: %#v\n", cmab.Cache) + fmt.Printf(" RetryConfig: %#v\n", cmab.RetryConfig) + + // Base assertions + assert.True(t, cmab.Enabled) + assert.Equal(t, "https://custom-cmab-endpoint.example.com", cmab.PredictionEndpoint) + assert.Equal(t, 15*time.Second, cmab.RequestTimeout) + + // Check if cache map is initialized + cacheMap := cmab.Cache + if cacheMap == nil { + t.Fatal("Cache map is nil") + } + + // Debug cache type + cacheTypeValue := cacheMap["type"] + fmt.Printf("Cache type: %v (%T)\n", cacheTypeValue, cacheTypeValue) + assert.Equal(t, "redis", cacheTypeValue) + + // Debug cache size + cacheSizeValue := cacheMap["size"] + fmt.Printf("Cache size: %v (%T)\n", cacheSizeValue, cacheSizeValue) + sizeValue, ok := cacheSizeValue.(float64) + assert.True(t, ok, "Cache size should be float64") + assert.Equal(t, float64(2000), sizeValue) + + // Cache TTL + cacheTTLValue := cacheMap["ttl"] + fmt.Printf("Cache TTL: %v (%T)\n", cacheTTLValue, cacheTTLValue) + assert.Equal(t, "45m", cacheTTLValue) + + // Redis settings + redisValue := cacheMap["redis"] + fmt.Printf("Redis: %v (%T)\n", redisValue, redisValue) + redisMap, ok := redisValue.(map[string]interface{}) + assert.True(t, ok, "Redis should be a map") + + if !ok { + t.Fatal("Redis is not a map") + } + + redisHost := redisMap["host"] + fmt.Printf("Redis host: %v (%T)\n", redisHost, redisHost) + assert.Equal(t, "redis.example.com:6379", redisHost) + + redisPassword := redisMap["password"] + fmt.Printf("Redis password: %v (%T)\n", redisPassword, redisPassword) + assert.Equal(t, "password123", redisPassword) + + redisDBValue := redisMap["database"] + fmt.Printf("Redis DB: %v (%T)\n", redisDBValue, redisDBValue) + dbValue, ok := redisDBValue.(float64) + assert.True(t, ok, "Redis DB should be float64") + assert.Equal(t, float64(2), dbValue) + + // Retry settings + retryMap := cmab.RetryConfig + if retryMap == nil { + t.Fatal("RetryConfig map is nil") + } + + // Max retries + maxRetriesValue := retryMap["maxRetries"] + fmt.Printf("maxRetries: %v (%T)\n", maxRetriesValue, maxRetriesValue) + maxRetries, ok := maxRetriesValue.(float64) + assert.True(t, ok, "maxRetries should be float64") + assert.Equal(t, float64(5), maxRetries) + + // Check other retry settings + fmt.Printf("initialBackoff: %v (%T)\n", retryMap["initialBackoff"], retryMap["initialBackoff"]) + assert.Equal(t, "200ms", retryMap["initialBackoff"]) + + fmt.Printf("maxBackoff: %v (%T)\n", retryMap["maxBackoff"], retryMap["maxBackoff"]) + assert.Equal(t, "30s", retryMap["maxBackoff"]) + + fmt.Printf("backoffMultiplier: %v (%T)\n", retryMap["backoffMultiplier"], retryMap["backoffMultiplier"]) + assert.Equal(t, 3.0, retryMap["backoffMultiplier"]) +} + +func TestCMABEnvDebug(t *testing.T) { + _ = os.Setenv("OPTIMIZELY_CMAB", `{ + "enabled": true, + "predictionEndpoint": "https://custom-cmab-endpoint.example.com", + "requestTimeout": "15s", + "cache": { + "type": "redis", + "size": 2000, + "ttl": "45m", + "redis": { + "host": "redis.example.com:6379", + "password": "password123", + "database": 2 + } + }, + "retryConfig": { + "maxRetries": 5, + "initialBackoff": "200ms", + "maxBackoff": "30s", + "backoffMultiplier": 3.0 + } + }`) + + // Load config using Viper + v := viper.New() + v.SetEnvPrefix("optimizely") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + // Create config + assert.NoError(t, initConfig(v)) + conf := loadConfig(v) + + // Debug: Print the parsed config + fmt.Println("Parsed CMAB config from JSON env var:") + fmt.Printf(" Enabled: %v\n", conf.CMAB.Enabled) + fmt.Printf(" PredictionEndpoint: %s\n", conf.CMAB.PredictionEndpoint) + fmt.Printf(" RequestTimeout: %v\n", conf.CMAB.RequestTimeout) + fmt.Printf(" Cache: %+v\n", conf.CMAB.Cache) + fmt.Printf(" RetryConfig: %+v\n", conf.CMAB.RetryConfig) + + // Call assertCMAB + assertCMAB(t, conf.CMAB) +} + +func TestCMABPartialConfig(t *testing.T) { + // Clean any existing environment variables + os.Unsetenv("OPTIMIZELY_CMAB") + os.Unsetenv("OPTIMIZELY_CMAB_CACHE") + os.Unsetenv("OPTIMIZELY_CMAB_RETRYCONFIG") + + // Set partial configuration through CMAB_CACHE and CMAB_RETRYCONFIG + _ = os.Setenv("OPTIMIZELY_CMAB", `{"enabled": true, "predictionEndpoint": "https://base-endpoint.example.com"}`) + _ = os.Setenv("OPTIMIZELY_CMAB_CACHE", `{"type": "redis", "size": 3000}`) + _ = os.Setenv("OPTIMIZELY_CMAB_RETRYCONFIG", `{"maxRetries": 10}`) + + // Load config + v := viper.New() + assert.NoError(t, initConfig(v)) + conf := loadConfig(v) + + // Base assertions + assert.True(t, conf.CMAB.Enabled) + assert.Equal(t, "https://base-endpoint.example.com", conf.CMAB.PredictionEndpoint) + + // Cache assertions + assert.Equal(t, "redis", conf.CMAB.Cache["type"]) + assert.Equal(t, float64(3000), conf.CMAB.Cache["size"]) + + // RetryConfig assertions + assert.Equal(t, float64(10), conf.CMAB.RetryConfig["maxRetries"]) + + // Clean up + os.Unsetenv("OPTIMIZELY_CMAB") + os.Unsetenv("OPTIMIZELY_CMAB_CACHE") + os.Unsetenv("OPTIMIZELY_CMAB_RETRYCONFIG") +} + func TestViperYaml(t *testing.T) { v := viper.New() v.Set("config.filename", "./testdata/default.yaml") @@ -392,6 +556,28 @@ func TestViperEnv(t *testing.T) { _ = os.Setenv("OPTIMIZELY_WEBHOOK_PROJECTS_20000_SDKKEYS", "xxx,yyy,zzz") _ = os.Setenv("OPTIMIZELY_WEBHOOK_PROJECTS_20000_SKIPSIGNATURECHECK", "false") + _ = os.Setenv("OPTIMIZELY_CMAB", `{ + "enabled": true, + "predictionEndpoint": "https://custom-cmab-endpoint.example.com", + "requestTimeout": "15s", + "cache": { + "type": "redis", + "size": 2000, + "ttl": "45m", + "redis": { + "host": "redis.example.com:6379", + "password": "password123", + "database": 2 + } + }, + "retryConfig": { + "maxRetries": 5, + "initialBackoff": "200ms", + "maxBackoff": "30s", + "backoffMultiplier": 3.0 + } + }`) + _ = os.Setenv("OPTIMIZELY_RUNTIME_BLOCKPROFILERATE", "1") _ = os.Setenv("OPTIMIZELY_RUNTIME_MUTEXPROFILEFRACTION", "2") @@ -407,6 +593,7 @@ func TestViperEnv(t *testing.T) { assertAPI(t, actual.API) //assertWebhook(t, actual.Webhook) // Maps don't appear to be supported assertRuntime(t, actual.Runtime) + assertCMAB(t, actual.CMAB) } func TestLoggingWithIncludeSdkKey(t *testing.T) { @@ -507,3 +694,45 @@ func Test_initTracing(t *testing.T) { }) } } + +func TestCMABComplexJSON(t *testing.T) { + // Clean any existing environment variables for CMAB + os.Unsetenv("OPTIMIZELY_CMAB_CACHE_TYPE") + os.Unsetenv("OPTIMIZELY_CMAB_CACHE_SIZE") + os.Unsetenv("OPTIMIZELY_CMAB_CACHE_TTL") + os.Unsetenv("OPTIMIZELY_CMAB_CACHE_REDIS_HOST") + os.Unsetenv("OPTIMIZELY_CMAB_CACHE_REDIS_PASSWORD") + os.Unsetenv("OPTIMIZELY_CMAB_CACHE_REDIS_DATABASE") + + // Set complex JSON environment variable for CMAB cache + _ = os.Setenv("OPTIMIZELY_CMAB_CACHE", `{"type":"redis","size":5000,"ttl":"3h","redis":{"host":"json-redis.example.com:6379","password":"json-password","database":4}}`) + + defer func() { + // Clean up + os.Unsetenv("OPTIMIZELY_CMAB_CACHE") + }() + + v := viper.New() + assert.NoError(t, initConfig(v)) + actual := loadConfig(v) + + // Test cache settings from JSON environment variable + cacheMap := actual.CMAB.Cache + assert.Equal(t, "redis", cacheMap["type"]) + + // Account for JSON unmarshaling to float64 + size, ok := cacheMap["size"].(float64) + assert.True(t, ok, "Size should be a float64") + assert.Equal(t, float64(5000), size) + + assert.Equal(t, "3h", cacheMap["ttl"]) + + redisMap, ok := cacheMap["redis"].(map[string]interface{}) + assert.True(t, ok, "Redis config should be a map") + assert.Equal(t, "json-redis.example.com:6379", redisMap["host"]) + assert.Equal(t, "json-password", redisMap["password"]) + + db, ok := redisMap["database"].(float64) + assert.True(t, ok, "Database should be a float64") + assert.Equal(t, float64(4), db) +} diff --git a/config.yaml b/config.yaml index d3145d3b..082075ff 100644 --- a/config.yaml +++ b/config.yaml @@ -262,3 +262,37 @@ synchronization: datafile: enable: false default: "redis" + +## +## cmab: Contextual Multi-Armed Bandit configuration +## +cmab: + ## enable or disable CMAB functionality + enabled: false + ## URL for CMAB prediction service + predictionEndpoint: "https://prediction.cmab.optimizely.com" + ## timeout for CMAB API requests + requestTimeout: 10s + ## CMAB cache configuration + cache: + ## cache type (memory or redis) + type: "memory" + ## maximum number of entries for in-memory cache + size: 1000 + ## time-to-live for cached decisions + ttl: 30m + ## Redis configuration (if using redis cache) + redis: + host: "localhost:6379" + password: "" + database: 0 + ## retry configuration for CMAB API requests + retryConfig: + ## maximum number of retry attempts + maxRetries: 3 + ## initial backoff duration + initialBackoff: 100ms + ## maximum backoff duration + maxBackoff: 10s + ## multiplier for exponential backoff + backoffMultiplier: 2.0 diff --git a/config/config.go b/config/config.go index 9e652910..f3291d3e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020,2022-2023, Optimizely, Inc. and contributors * + * Copyright 2019-2020,2022-2025, 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. * @@ -140,8 +140,28 @@ func NewDefaultConfig() *AgentConfig { Default: "redis", }, }, + CMAB: CMABConfig{ + Enabled: false, + PredictionEndpoint: "https://prediction.cmab.optimizely.com", + RequestTimeout: 10 * time.Second, + Cache: map[string]interface{}{ + "type": "memory", + "size": 1000, + "ttl": "30m", + "redis": map[string]interface{}{ + "host": "localhost:6379", + "password": "", + "database": 0, + }, + }, + RetryConfig: map[string]interface{}{ + "maxRetries": 3, + "initialBackoff": "100ms", + "maxBackoff": "10s", + "backoffMultiplier": 2.0, + }, + }, } - return &config } @@ -162,6 +182,7 @@ type AgentConfig struct { Server ServerConfig `json:"server"` Webhook WebhookConfig `json:"webhook"` Synchronization SyncConfig `json:"synchronization"` + CMAB CMABConfig `json:"cmab"` } // SyncConfig contains Synchronization configuration for the multiple Agent nodes @@ -387,3 +408,21 @@ type RuntimeConfig struct { // (For n>1 the details of sampling may change.) MutexProfileFraction int `json:"mutexProfileFraction"` } + +// CMABConfig holds configuration for the Contextual Multi-Armed Bandit functionality +type CMABConfig struct { + // Enabled indicates whether the CMAB functionality is enabled + Enabled bool `json:"enabled"` + + // PredictionEndpoint is the URL for CMAB predictions + PredictionEndpoint string `json:"predictionEndpoint"` + + // RequestTimeout is the timeout for CMAB API requests + RequestTimeout time.Duration `json:"requestTimeout"` + + // Cache configuration + Cache map[string]interface{} `json:"cache"` + + // RetryConfig for CMAB API requests + RetryConfig map[string]interface{} `json:"retryConfig"` +} diff --git a/config/config_test.go b/config/config_test.go index 917cd498..a5f3a8a4 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2019-2020,2022-2023, Optimizely, Inc. and contributors * + * Copyright 2019-2020,2022-2025, 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. * @@ -99,6 +99,29 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, 0, conf.Runtime.BlockProfileRate) assert.Equal(t, 0, conf.Runtime.MutexProfileFraction) + + // CMAB configuration + assert.False(t, conf.CMAB.Enabled) + assert.Equal(t, "https://prediction.cmab.optimizely.com", conf.CMAB.PredictionEndpoint) + assert.Equal(t, 10*time.Second, conf.CMAB.RequestTimeout) + + // Test cache settings as maps + cacheMap := conf.CMAB.Cache + assert.Equal(t, "memory", cacheMap["type"]) + assert.Equal(t, 1000, cacheMap["size"]) + assert.Equal(t, "30m", cacheMap["ttl"]) + + redisMap := cacheMap["redis"].(map[string]interface{}) + assert.Equal(t, "localhost:6379", redisMap["host"]) + assert.Equal(t, "", redisMap["password"]) + assert.Equal(t, 0, redisMap["database"]) + + // Test retry settings as maps + retryMap := conf.CMAB.RetryConfig + assert.Equal(t, 3, retryMap["maxRetries"]) + assert.Equal(t, "100ms", retryMap["initialBackoff"]) + assert.Equal(t, "10s", retryMap["maxBackoff"]) + assert.Equal(t, 2.0, retryMap["backoffMultiplier"]) } type logObservation struct { @@ -233,3 +256,30 @@ func TestServerConfig_GetAllowedHosts(t *testing.T) { assert.Contains(t, allowedHosts, "localhost") assert.Contains(t, allowedHosts, "special.test.host") } + +func TestDefaultCMABConfig(t *testing.T) { + conf := NewDefaultConfig() + + // Test default values + assert.False(t, conf.CMAB.Enabled) + assert.Equal(t, "https://prediction.cmab.optimizely.com", conf.CMAB.PredictionEndpoint) + assert.Equal(t, 10*time.Second, conf.CMAB.RequestTimeout) + + // Test default cache settings as maps + cacheMap := conf.CMAB.Cache + assert.Equal(t, "memory", cacheMap["type"]) + assert.Equal(t, 1000, cacheMap["size"]) + assert.Equal(t, "30m", cacheMap["ttl"]) + + redisMap := cacheMap["redis"].(map[string]interface{}) + assert.Equal(t, "localhost:6379", redisMap["host"]) + assert.Equal(t, "", redisMap["password"]) + assert.Equal(t, 0, redisMap["database"]) + + // Test default retry settings as maps + retryMap := conf.CMAB.RetryConfig + assert.Equal(t, 3, retryMap["maxRetries"]) + assert.Equal(t, "100ms", retryMap["initialBackoff"]) + assert.Equal(t, "10s", retryMap["maxBackoff"]) + assert.Equal(t, 2.0, retryMap["backoffMultiplier"]) +} From c2563795f2a78374cb14d9df6523c608878ef4d1 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Fri, 20 Jun 2025 15:28:07 -0700 Subject: [PATCH 2/4] remove enabled config, remove 3 ENV vars for cmab, only use a single one --- README.md | 39 +++++-------------------------------- cmd/optimizely/main.go | 3 --- cmd/optimizely/main_test.go | 4 ---- config.yaml | 9 +-------- config/config.go | 11 +---------- config/config_test.go | 16 ++------------- 6 files changed, 9 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 187a468c..2c23aa84 100644 --- a/README.md +++ b/README.md @@ -145,23 +145,16 @@ Below is a comprehensive list of available configuration properties. | webhook.projects.<_projectId_>.secret | N/A | Webhook secret used to validate webhook requests originating from the respective projectId | | webhook.projects.<_projectId_>.skipSignatureCheck | N/A | Boolean to indicate whether the signature should be validated. TODO remove in favor of empty secret. | -### CMAB Configuration Examples +### CMAB Configuration Example -**Complete CMAB Configuration (OPTIMIZELY_CMAB)**: ```json { - "enabled": true, - "predictionEndpoint": "https://custom-endpoint.com", + "predictionEndpoint": "https://prediction.cmab.optimizely.com/predict/%s", "requestTimeout": "5s", "cache": { - "type": "redis", + "type": "memory", "size": 2000, - "ttl": "45m", - "redis": { - "host": "redis:6379", - "password": "", - "database": 0 - } + "ttl": "45m" }, "retryConfig": { "maxRetries": 3, @@ -169,29 +162,7 @@ Below is a comprehensive list of available configuration properties. "maxBackoff": "10s", "backoffMultiplier": 2.0 } -} - -CMAB Cache Configuration (OPTIMIZELY_CMAB_CACHE): - -{ - "type": "redis", - "size": 2000, - "ttl": "45m", - "redis": { - "host": "redis:6379", - "password": "", - "database": 0 - } -} - -CMAB Retry Configuration (OPTIMIZELY_CMAB_RETRYCONFIG): - -{ - "maxRetries": 3, - "initialBackoff": "100ms", - "maxBackoff": "10s", - "backoffMultiplier": 2.0 -} +}``` More information about configuring Agent can be found in the [Advanced Configuration Notes](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/advanced-configuration). diff --git a/cmd/optimizely/main.go b/cmd/optimizely/main.go index b7ad6976..1f00d09e 100644 --- a/cmd/optimizely/main.go +++ b/cmd/optimizely/main.go @@ -111,9 +111,6 @@ func loadConfig(v *viper.Viper) *config.AgentConfig { // Handle CMAB configuration using the same approach as UserProfileService // Check for complete CMAB configuration first if cmab := v.GetStringMap("cmab"); len(cmab) > 0 { - if enabled, ok := cmab["enabled"].(bool); ok { - conf.CMAB.Enabled = enabled - } if endpoint, ok := cmab["predictionEndpoint"].(string); ok { conf.CMAB.PredictionEndpoint = endpoint } diff --git a/cmd/optimizely/main_test.go b/cmd/optimizely/main_test.go index 42ea8d3b..88c837d5 100644 --- a/cmd/optimizely/main_test.go +++ b/cmd/optimizely/main_test.go @@ -182,14 +182,12 @@ func assertWebhook(t *testing.T, actual config.WebhookConfig) { func assertCMAB(t *testing.T, cmab config.CMABConfig) { fmt.Println("In assertCMAB, received CMAB config:") - fmt.Printf(" Enabled: %v\n", cmab.Enabled) fmt.Printf(" PredictionEndpoint: %s\n", cmab.PredictionEndpoint) fmt.Printf(" RequestTimeout: %v\n", cmab.RequestTimeout) fmt.Printf(" Cache: %#v\n", cmab.Cache) fmt.Printf(" RetryConfig: %#v\n", cmab.RetryConfig) // Base assertions - assert.True(t, cmab.Enabled) assert.Equal(t, "https://custom-cmab-endpoint.example.com", cmab.PredictionEndpoint) assert.Equal(t, 15*time.Second, cmab.RequestTimeout) @@ -299,7 +297,6 @@ func TestCMABEnvDebug(t *testing.T) { // Debug: Print the parsed config fmt.Println("Parsed CMAB config from JSON env var:") - fmt.Printf(" Enabled: %v\n", conf.CMAB.Enabled) fmt.Printf(" PredictionEndpoint: %s\n", conf.CMAB.PredictionEndpoint) fmt.Printf(" RequestTimeout: %v\n", conf.CMAB.RequestTimeout) fmt.Printf(" Cache: %+v\n", conf.CMAB.Cache) @@ -326,7 +323,6 @@ func TestCMABPartialConfig(t *testing.T) { conf := loadConfig(v) // Base assertions - assert.True(t, conf.CMAB.Enabled) assert.Equal(t, "https://base-endpoint.example.com", conf.CMAB.PredictionEndpoint) // Cache assertions diff --git a/config.yaml b/config.yaml index 082075ff..76de2b40 100644 --- a/config.yaml +++ b/config.yaml @@ -267,10 +267,8 @@ synchronization: ## cmab: Contextual Multi-Armed Bandit configuration ## cmab: - ## enable or disable CMAB functionality - enabled: false ## URL for CMAB prediction service - predictionEndpoint: "https://prediction.cmab.optimizely.com" + predictionEndpoint: "https://prediction.cmab.optimizely.com/predict/%s" ## timeout for CMAB API requests requestTimeout: 10s ## CMAB cache configuration @@ -281,11 +279,6 @@ cmab: size: 1000 ## time-to-live for cached decisions ttl: 30m - ## Redis configuration (if using redis cache) - redis: - host: "localhost:6379" - password: "" - database: 0 ## retry configuration for CMAB API requests retryConfig: ## maximum number of retry attempts diff --git a/config/config.go b/config/config.go index f3291d3e..4da7e95d 100644 --- a/config/config.go +++ b/config/config.go @@ -141,18 +141,12 @@ func NewDefaultConfig() *AgentConfig { }, }, CMAB: CMABConfig{ - Enabled: false, - PredictionEndpoint: "https://prediction.cmab.optimizely.com", + PredictionEndpoint: "https://prediction.cmab.optimizely.com/predict/%s", RequestTimeout: 10 * time.Second, Cache: map[string]interface{}{ "type": "memory", "size": 1000, "ttl": "30m", - "redis": map[string]interface{}{ - "host": "localhost:6379", - "password": "", - "database": 0, - }, }, RetryConfig: map[string]interface{}{ "maxRetries": 3, @@ -411,9 +405,6 @@ type RuntimeConfig struct { // CMABConfig holds configuration for the Contextual Multi-Armed Bandit functionality type CMABConfig struct { - // Enabled indicates whether the CMAB functionality is enabled - Enabled bool `json:"enabled"` - // PredictionEndpoint is the URL for CMAB predictions PredictionEndpoint string `json:"predictionEndpoint"` diff --git a/config/config_test.go b/config/config_test.go index a5f3a8a4..42de8f74 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -101,8 +101,7 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, 0, conf.Runtime.MutexProfileFraction) // CMAB configuration - assert.False(t, conf.CMAB.Enabled) - assert.Equal(t, "https://prediction.cmab.optimizely.com", conf.CMAB.PredictionEndpoint) + assert.Equal(t, "https://prediction.cmab.optimizely.com/predict/%s", conf.CMAB.PredictionEndpoint) assert.Equal(t, 10*time.Second, conf.CMAB.RequestTimeout) // Test cache settings as maps @@ -111,11 +110,6 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, 1000, cacheMap["size"]) assert.Equal(t, "30m", cacheMap["ttl"]) - redisMap := cacheMap["redis"].(map[string]interface{}) - assert.Equal(t, "localhost:6379", redisMap["host"]) - assert.Equal(t, "", redisMap["password"]) - assert.Equal(t, 0, redisMap["database"]) - // Test retry settings as maps retryMap := conf.CMAB.RetryConfig assert.Equal(t, 3, retryMap["maxRetries"]) @@ -261,8 +255,7 @@ func TestDefaultCMABConfig(t *testing.T) { conf := NewDefaultConfig() // Test default values - assert.False(t, conf.CMAB.Enabled) - assert.Equal(t, "https://prediction.cmab.optimizely.com", conf.CMAB.PredictionEndpoint) + assert.Equal(t, "https://prediction.cmab.optimizely.com/predict/%s", conf.CMAB.PredictionEndpoint) assert.Equal(t, 10*time.Second, conf.CMAB.RequestTimeout) // Test default cache settings as maps @@ -271,11 +264,6 @@ func TestDefaultCMABConfig(t *testing.T) { assert.Equal(t, 1000, cacheMap["size"]) assert.Equal(t, "30m", cacheMap["ttl"]) - redisMap := cacheMap["redis"].(map[string]interface{}) - assert.Equal(t, "localhost:6379", redisMap["host"]) - assert.Equal(t, "", redisMap["password"]) - assert.Equal(t, 0, redisMap["database"]) - // Test default retry settings as maps retryMap := conf.CMAB.RetryConfig assert.Equal(t, 3, retryMap["maxRetries"]) From f3430cf8d3bf1ae021affd5f12936ab1cd092576 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Fri, 20 Jun 2025 15:30:12 -0700 Subject: [PATCH 3/4] cleanup readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c23aa84..a5f9ded6 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,8 @@ Below is a comprehensive list of available configuration properties. "maxBackoff": "10s", "backoffMultiplier": 2.0 } -}``` +} +``` More information about configuring Agent can be found in the [Advanced Configuration Notes](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/advanced-configuration). From 67382b04a3a1fecc545f5b6e862b3505867710ec Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Wed, 25 Jun 2025 14:54:18 -0700 Subject: [PATCH 4/4] add cmab logic to agent --- README.md | 1 - cmd/optimizely/main.go | 3 - cmd/optimizely/main_test.go | 11 - config.yaml | 2 - config/config.go | 7 +- config/config_test.go | 2 - pkg/optimizely/cache.go | 66 ++++- pkg/optimizely/cache_test.go | 282 ++++++++++++++++++- pkg/optimizely/client.go | 2 +- pkg/optimizely/optimizelytest/config.go | 20 ++ plugins/odpcache/registry.go | 2 +- plugins/odpcache/registry_test.go | 2 +- plugins/odpcache/services/in_memory_cache.go | 2 +- plugins/odpcache/services/redis_cache.go | 2 +- 14 files changed, 373 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index a5f9ded6..15ff2e36 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,6 @@ Below is a comprehensive list of available configuration properties. ```json { - "predictionEndpoint": "https://prediction.cmab.optimizely.com/predict/%s", "requestTimeout": "5s", "cache": { "type": "memory", diff --git a/cmd/optimizely/main.go b/cmd/optimizely/main.go index 1f00d09e..29dfaadf 100644 --- a/cmd/optimizely/main.go +++ b/cmd/optimizely/main.go @@ -111,9 +111,6 @@ func loadConfig(v *viper.Viper) *config.AgentConfig { // Handle CMAB configuration using the same approach as UserProfileService // Check for complete CMAB configuration first if cmab := v.GetStringMap("cmab"); len(cmab) > 0 { - if endpoint, ok := cmab["predictionEndpoint"].(string); ok { - conf.CMAB.PredictionEndpoint = endpoint - } if timeout, ok := cmab["requestTimeout"].(string); ok { if duration, err := time.ParseDuration(timeout); err == nil { conf.CMAB.RequestTimeout = duration diff --git a/cmd/optimizely/main_test.go b/cmd/optimizely/main_test.go index 88c837d5..48336a6f 100644 --- a/cmd/optimizely/main_test.go +++ b/cmd/optimizely/main_test.go @@ -182,13 +182,11 @@ func assertWebhook(t *testing.T, actual config.WebhookConfig) { func assertCMAB(t *testing.T, cmab config.CMABConfig) { fmt.Println("In assertCMAB, received CMAB config:") - fmt.Printf(" PredictionEndpoint: %s\n", cmab.PredictionEndpoint) fmt.Printf(" RequestTimeout: %v\n", cmab.RequestTimeout) fmt.Printf(" Cache: %#v\n", cmab.Cache) fmt.Printf(" RetryConfig: %#v\n", cmab.RetryConfig) // Base assertions - assert.Equal(t, "https://custom-cmab-endpoint.example.com", cmab.PredictionEndpoint) assert.Equal(t, 15*time.Second, cmab.RequestTimeout) // Check if cache map is initialized @@ -264,8 +262,6 @@ func assertCMAB(t *testing.T, cmab config.CMABConfig) { func TestCMABEnvDebug(t *testing.T) { _ = os.Setenv("OPTIMIZELY_CMAB", `{ - "enabled": true, - "predictionEndpoint": "https://custom-cmab-endpoint.example.com", "requestTimeout": "15s", "cache": { "type": "redis", @@ -297,7 +293,6 @@ func TestCMABEnvDebug(t *testing.T) { // Debug: Print the parsed config fmt.Println("Parsed CMAB config from JSON env var:") - fmt.Printf(" PredictionEndpoint: %s\n", conf.CMAB.PredictionEndpoint) fmt.Printf(" RequestTimeout: %v\n", conf.CMAB.RequestTimeout) fmt.Printf(" Cache: %+v\n", conf.CMAB.Cache) fmt.Printf(" RetryConfig: %+v\n", conf.CMAB.RetryConfig) @@ -313,7 +308,6 @@ func TestCMABPartialConfig(t *testing.T) { os.Unsetenv("OPTIMIZELY_CMAB_RETRYCONFIG") // Set partial configuration through CMAB_CACHE and CMAB_RETRYCONFIG - _ = os.Setenv("OPTIMIZELY_CMAB", `{"enabled": true, "predictionEndpoint": "https://base-endpoint.example.com"}`) _ = os.Setenv("OPTIMIZELY_CMAB_CACHE", `{"type": "redis", "size": 3000}`) _ = os.Setenv("OPTIMIZELY_CMAB_RETRYCONFIG", `{"maxRetries": 10}`) @@ -322,9 +316,6 @@ func TestCMABPartialConfig(t *testing.T) { assert.NoError(t, initConfig(v)) conf := loadConfig(v) - // Base assertions - assert.Equal(t, "https://base-endpoint.example.com", conf.CMAB.PredictionEndpoint) - // Cache assertions assert.Equal(t, "redis", conf.CMAB.Cache["type"]) assert.Equal(t, float64(3000), conf.CMAB.Cache["size"]) @@ -553,8 +544,6 @@ func TestViperEnv(t *testing.T) { _ = os.Setenv("OPTIMIZELY_WEBHOOK_PROJECTS_20000_SKIPSIGNATURECHECK", "false") _ = os.Setenv("OPTIMIZELY_CMAB", `{ - "enabled": true, - "predictionEndpoint": "https://custom-cmab-endpoint.example.com", "requestTimeout": "15s", "cache": { "type": "redis", diff --git a/config.yaml b/config.yaml index 76de2b40..6dc98b31 100644 --- a/config.yaml +++ b/config.yaml @@ -267,8 +267,6 @@ synchronization: ## cmab: Contextual Multi-Armed Bandit configuration ## cmab: - ## URL for CMAB prediction service - predictionEndpoint: "https://prediction.cmab.optimizely.com/predict/%s" ## timeout for CMAB API requests requestTimeout: 10s ## CMAB cache configuration diff --git a/config/config.go b/config/config.go index 4da7e95d..9a90f08a 100644 --- a/config/config.go +++ b/config/config.go @@ -141,8 +141,7 @@ func NewDefaultConfig() *AgentConfig { }, }, CMAB: CMABConfig{ - PredictionEndpoint: "https://prediction.cmab.optimizely.com/predict/%s", - RequestTimeout: 10 * time.Second, + RequestTimeout: 10 * time.Second, Cache: map[string]interface{}{ "type": "memory", "size": 1000, @@ -230,6 +229,7 @@ type ClientConfig struct { SdkKeyRegex string `json:"sdkKeyRegex"` UserProfileService UserProfileServiceConfigs `json:"userProfileService"` ODP OdpConfig `json:"odp"` + CMAB CMABConfig `json:"cmab" mapstructure:"cmab"` } // OdpConfig holds the odp configuration @@ -405,9 +405,6 @@ type RuntimeConfig struct { // CMABConfig holds configuration for the Contextual Multi-Armed Bandit functionality type CMABConfig struct { - // PredictionEndpoint is the URL for CMAB predictions - PredictionEndpoint string `json:"predictionEndpoint"` - // RequestTimeout is the timeout for CMAB API requests RequestTimeout time.Duration `json:"requestTimeout"` diff --git a/config/config_test.go b/config/config_test.go index 42de8f74..1f23eed0 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -101,7 +101,6 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, 0, conf.Runtime.MutexProfileFraction) // CMAB configuration - assert.Equal(t, "https://prediction.cmab.optimizely.com/predict/%s", conf.CMAB.PredictionEndpoint) assert.Equal(t, 10*time.Second, conf.CMAB.RequestTimeout) // Test cache settings as maps @@ -255,7 +254,6 @@ func TestDefaultCMABConfig(t *testing.T) { conf := NewDefaultConfig() // Test default values - assert.Equal(t, "https://prediction.cmab.optimizely.com/predict/%s", conf.CMAB.PredictionEndpoint) assert.Equal(t, 10*time.Second, conf.CMAB.RequestTimeout) // Test default cache settings as maps diff --git a/pkg/optimizely/cache.go b/pkg/optimizely/cache.go index 093f6585..17763d54 100644 --- a/pkg/optimizely/cache.go +++ b/pkg/optimizely/cache.go @@ -21,9 +21,11 @@ import ( "context" "encoding/json" "errors" + "net/http" "regexp" "strings" "sync" + "time" cmap "github.com/orcaman/concurrent-map" "github.com/rs/zerolog/log" @@ -33,13 +35,14 @@ import ( "github.com/optimizely/agent/pkg/syncer" "github.com/optimizely/agent/plugins/odpcache" "github.com/optimizely/agent/plugins/userprofileservice" + odpCachePkg "github.com/optimizely/go-sdk/v2/pkg/cache" "github.com/optimizely/go-sdk/v2/pkg/client" + cmab "github.com/optimizely/go-sdk/v2/pkg/cmab" sdkconfig "github.com/optimizely/go-sdk/v2/pkg/config" "github.com/optimizely/go-sdk/v2/pkg/decision" "github.com/optimizely/go-sdk/v2/pkg/event" "github.com/optimizely/go-sdk/v2/pkg/logging" "github.com/optimizely/go-sdk/v2/pkg/odp" - odpCachePkg "github.com/optimizely/go-sdk/v2/pkg/odp/cache" odpEventPkg "github.com/optimizely/go-sdk/v2/pkg/odp/event" odpSegmentPkg "github.com/optimizely/go-sdk/v2/pkg/odp/segment" "github.com/optimizely/go-sdk/v2/pkg/tracing" @@ -312,6 +315,67 @@ func defaultLoader( ) clientOptions = append(clientOptions, client.WithOdpManager(odpManager)) + // Parse CMAB cache configuration + cacheSize := 1000 // default + cacheTTL := 30 * time.Minute // default + + if cacheConfig, ok := clientConf.CMAB.Cache["size"].(int); ok { + cacheSize = cacheConfig + } + + if cacheTTLStr, ok := clientConf.CMAB.Cache["ttl"].(string); ok { + if parsedTTL, err := time.ParseDuration(cacheTTLStr); err == nil { + cacheTTL = parsedTTL + } else { + log.Warn().Err(err).Msgf("Failed to parse CMAB cache TTL: %s, using default", cacheTTLStr) + } + } + + // Parse retry configuration + retryConfig := &cmab.RetryConfig{ + MaxRetries: 3, + InitialBackoff: 100 * time.Millisecond, + MaxBackoff: 10 * time.Second, + BackoffMultiplier: 2.0, + } + + if maxRetries, ok := clientConf.CMAB.RetryConfig["maxRetries"].(int); ok { + retryConfig.MaxRetries = maxRetries + } + + if initialBackoffStr, ok := clientConf.CMAB.RetryConfig["initialBackoff"].(string); ok { + if parsedBackoff, err := time.ParseDuration(initialBackoffStr); err == nil { + retryConfig.InitialBackoff = parsedBackoff + } + } + + if maxBackoffStr, ok := clientConf.CMAB.RetryConfig["maxBackoff"].(string); ok { + if parsedBackoff, err := time.ParseDuration(maxBackoffStr); err == nil { + retryConfig.MaxBackoff = parsedBackoff + } + } + + if multiplier, ok := clientConf.CMAB.RetryConfig["backoffMultiplier"].(float64); ok { + retryConfig.BackoffMultiplier = multiplier + } + + // Create CMAB client and service + cmabClient := cmab.NewDefaultCmabClient(cmab.ClientOptions{ + HTTPClient: &http.Client{ + Timeout: clientConf.CMAB.RequestTimeout, + }, + RetryConfig: retryConfig, + Logger: logging.GetLogger(sdkKey, "CmabClient"), + }) + + cmabService := cmab.NewDefaultCmabService(cmab.ServiceOptions{ + Logger: logging.GetLogger(sdkKey, "CmabService"), + CmabCache: odpCachePkg.NewLRUCache(cacheSize, cacheTTL), + CmabClient: cmabClient, + }) + + clientOptions = append(clientOptions, client.WithCmabService(cmabService)) + optimizelyClient, err := optimizelyFactory.Client( clientOptions..., ) diff --git a/pkg/optimizely/cache_test.go b/pkg/optimizely/cache_test.go index ec2b8ddb..62a44b5b 100644 --- a/pkg/optimizely/cache_test.go +++ b/pkg/optimizely/cache_test.go @@ -34,10 +34,10 @@ import ( odpCacheServices "github.com/optimizely/agent/plugins/odpcache/services" "github.com/optimizely/agent/plugins/userprofileservice" "github.com/optimizely/agent/plugins/userprofileservice/services" + "github.com/optimizely/go-sdk/v2/pkg/cache" sdkconfig "github.com/optimizely/go-sdk/v2/pkg/config" "github.com/optimizely/go-sdk/v2/pkg/decision" "github.com/optimizely/go-sdk/v2/pkg/event" - "github.com/optimizely/go-sdk/v2/pkg/odp/cache" ) var counter int @@ -795,6 +795,286 @@ func (s *DefaultLoaderTestSuite) TestDefaultRegexValidator() { } } +// Add these tests to your existing cache_test.go file + +func (s *DefaultLoaderTestSuite) TestCMABConfigurationParsing() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + CMAB: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: map[string]interface{}{ + "size": 500, + "ttl": "15m", + }, + RetryConfig: map[string]interface{}{ + "maxRetries": 5, + "initialBackoff": "200ms", + "maxBackoff": "30s", + "backoffMultiplier": 1.5, + }, + }, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err) + s.NotNil(client) + // Note: We can't directly test the CMAB service since it's internal to the OptimizelyClient + // But we can verify the loader doesn't error with valid CMAB config +} + +func (s *DefaultLoaderTestSuite) TestCMABConfigurationDefaults() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + CMAB: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + // Empty cache and retry config should use defaults + Cache: map[string]interface{}{}, + RetryConfig: map[string]interface{}{}, + }, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err) + s.NotNil(client) +} + +func (s *DefaultLoaderTestSuite) TestCMABCacheConfigInvalidTTL() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + CMAB: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: map[string]interface{}{ + "size": 1000, + "ttl": "invalid-duration", // This should trigger warning and use default + }, + RetryConfig: map[string]interface{}{}, + }, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err) // Should not error, just use defaults + s.NotNil(client) +} + +func (s *DefaultLoaderTestSuite) TestCMABCacheConfigWrongTypes() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + CMAB: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: map[string]interface{}{ + "size": "not-an-int", // Wrong type, should use default + "ttl": 123, // Wrong type, should use default + }, + RetryConfig: map[string]interface{}{ + "maxRetries": "not-an-int", // Wrong type, should use default + "backoffMultiplier": "not-a-float", // Wrong type, should use default + }, + }, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err) // Should not error, just use defaults + s.NotNil(client) +} + +func (s *DefaultLoaderTestSuite) TestCMABRetryConfigInvalidDurations() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + CMAB: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: map[string]interface{}{}, + RetryConfig: map[string]interface{}{ + "maxRetries": 3, + "initialBackoff": "invalid-duration", + "maxBackoff": "also-invalid", + "backoffMultiplier": 2.0, + }, + }, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err) // Should not error, just use defaults for invalid durations + s.NotNil(client) +} + +func (s *DefaultLoaderTestSuite) TestCMABConfigurationAllValidValues() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + CMAB: config.CMABConfig{ + RequestTimeout: 10 * time.Second, + Cache: map[string]interface{}{ + "size": 2000, + "ttl": "45m", + }, + RetryConfig: map[string]interface{}{ + "maxRetries": 10, + "initialBackoff": "500ms", + "maxBackoff": "1m", + "backoffMultiplier": 3.0, + }, + }, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err) + s.NotNil(client) +} + +func (s *DefaultLoaderTestSuite) TestCMABWithZeroRequestTimeout() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + CMAB: config.CMABConfig{ + RequestTimeout: 0, // Zero timeout + Cache: map[string]interface{}{}, + RetryConfig: map[string]interface{}{}, + }, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err) + s.NotNil(client) +} + +func (s *DefaultLoaderTestSuite) TestCMABConfigurationEdgeCases() { + testCases := []struct { + name string + config config.CMABConfig + }{ + { + name: "Zero cache size", + config: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: map[string]interface{}{ + "size": 0, + "ttl": "30m", + }, + RetryConfig: map[string]interface{}{}, + }, + }, + { + name: "Zero max retries", + config: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: map[string]interface{}{}, + RetryConfig: map[string]interface{}{ + "maxRetries": 0, + }, + }, + }, + { + name: "Very short TTL", + config: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: map[string]interface{}{ + "ttl": "1ms", + }, + RetryConfig: map[string]interface{}{}, + }, + }, + { + name: "Very long TTL", + config: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: map[string]interface{}{ + "ttl": "24h", + }, + RetryConfig: map[string]interface{}{}, + }, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + CMAB: tc.config, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err, "Should not error for case: %s", tc.name) + s.NotNil(client, "Client should not be nil for case: %s", tc.name) + }) + } +} + +func (s *DefaultLoaderTestSuite) TestCMABConfigurationNilMaps() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + CMAB: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: nil, // nil map + RetryConfig: nil, // nil map + }, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err) + s.NotNil(client) +} + +// Test that CMAB configuration doesn't interfere with existing functionality +func (s *DefaultLoaderTestSuite) TestCMABWithExistingServices() { + conf := config.ClientConfig{ + SdkKeyRegex: "sdkkey", + UserProfileService: map[string]interface{}{ + "default": "in-memory", + "services": map[string]interface{}{ + "in-memory": map[string]interface{}{ + "capacity": 100, + "storageStrategy": "fifo", + }, + }, + }, + ODP: config.OdpConfig{ + SegmentsCache: map[string]interface{}{ + "default": "in-memory", + "services": map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": 50, + "timeout": "10s", + }, + }, + }, + }, + CMAB: config.CMABConfig{ + RequestTimeout: 5 * time.Second, + Cache: map[string]interface{}{ + "size": 1000, + "ttl": "30m", + }, + RetryConfig: map[string]interface{}{ + "maxRetries": 5, + }, + }, + } + + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + client, err := loader("sdkkey") + + s.NoError(err) + s.NotNil(client) + s.NotNil(client.UserProfileService, "UPS should still be configured") + s.NotNil(client.odpCache, "ODP Cache should still be configured") +} + func TestDefaultLoaderTestSuite(t *testing.T) { suite.Run(t, new(DefaultLoaderTestSuite)) } diff --git a/pkg/optimizely/client.go b/pkg/optimizely/client.go index 374171c1..850f413e 100644 --- a/pkg/optimizely/client.go +++ b/pkg/optimizely/client.go @@ -27,7 +27,7 @@ import ( optimizelyclient "github.com/optimizely/go-sdk/v2/pkg/client" "github.com/optimizely/go-sdk/v2/pkg/decision" "github.com/optimizely/go-sdk/v2/pkg/entities" - "github.com/optimizely/go-sdk/v2/pkg/odp/cache" + "github.com/optimizely/go-sdk/v2/pkg/cache" ) // ErrEntityNotFound is returned when no entity exists with a given key diff --git a/pkg/optimizely/optimizelytest/config.go b/pkg/optimizely/optimizelytest/config.go index f8bde648..e5cb0a57 100644 --- a/pkg/optimizely/optimizelytest/config.go +++ b/pkg/optimizely/optimizelytest/config.go @@ -518,6 +518,26 @@ func (c *TestProjectConfig) GetFlagVariationsMap() map[string][]entities.Variati return c.flagVariationsMap } +// GetAttributeKeyByID returns the attribute key for the given ID +func (c *TestProjectConfig) GetAttributeKeyByID(id string) (string, error) { + for _, attr := range c.AttributeMap { + if attr.ID == id { + return attr.Key, nil + } + } + return "", fmt.Errorf(`attribute with ID "%s" not found`, id) +} + +// GetExperimentByID returns the experiment with the given ID +func (c *TestProjectConfig) GetExperimentByID(experimentID string) (entities.Experiment, error) { + for _, experiment := range c.ExperimentMap { + if experiment.ID == experimentID { + return experiment, nil + } + } + return entities.Experiment{}, fmt.Errorf(`experiment with ID "%s" not found`, experimentID) +} + // NewConfig initializes a new datafile from a json byte array using the default JSON datafile parser func NewConfig() *TestProjectConfig { config := &TestProjectConfig{ diff --git a/plugins/odpcache/registry.go b/plugins/odpcache/registry.go index 20573067..326bd52d 100644 --- a/plugins/odpcache/registry.go +++ b/plugins/odpcache/registry.go @@ -20,7 +20,7 @@ package odpcache import ( "fmt" - "github.com/optimizely/go-sdk/v2/pkg/odp/cache" + "github.com/optimizely/go-sdk/v2/pkg/cache" ) // Creator type defines a function for creating an instance of a Cache diff --git a/plugins/odpcache/registry_test.go b/plugins/odpcache/registry_test.go index 08e8b315..4b58727d 100644 --- a/plugins/odpcache/registry_test.go +++ b/plugins/odpcache/registry_test.go @@ -20,7 +20,7 @@ package odpcache import ( "testing" - "github.com/optimizely/go-sdk/v2/pkg/odp/cache" + "github.com/optimizely/go-sdk/v2/pkg/cache" "github.com/stretchr/testify/assert" ) diff --git a/plugins/odpcache/services/in_memory_cache.go b/plugins/odpcache/services/in_memory_cache.go index dad28fda..1e1f4123 100644 --- a/plugins/odpcache/services/in_memory_cache.go +++ b/plugins/odpcache/services/in_memory_cache.go @@ -20,7 +20,7 @@ package services import ( "github.com/optimizely/agent/plugins/odpcache" "github.com/optimizely/agent/plugins/utils" - "github.com/optimizely/go-sdk/v2/pkg/odp/cache" + "github.com/optimizely/go-sdk/v2/pkg/cache" ) // InMemoryCache represents the in-memory implementation of Cache interface diff --git a/plugins/odpcache/services/redis_cache.go b/plugins/odpcache/services/redis_cache.go index 73612670..c52f847c 100644 --- a/plugins/odpcache/services/redis_cache.go +++ b/plugins/odpcache/services/redis_cache.go @@ -24,7 +24,7 @@ import ( "github.com/go-redis/redis/v8" "github.com/optimizely/agent/plugins/odpcache" "github.com/optimizely/agent/plugins/utils" - "github.com/optimizely/go-sdk/v2/pkg/odp/cache" + "github.com/optimizely/go-sdk/v2/pkg/cache" "github.com/rs/zerolog/log" )