diff --git a/README.md b/README.md index 5961a26b..15ff2e36 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,25 @@ 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 Example + +```json +{ + "requestTimeout": "5s", + "cache": { + "type": "memory", + "size": 2000, + "ttl": "45m" + }, + "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..29dfaadf 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,40 @@ 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 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..48336a6f 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,155 @@ 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(" RequestTimeout: %v\n", cmab.RequestTimeout) + fmt.Printf(" Cache: %#v\n", cmab.Cache) + fmt.Printf(" RetryConfig: %#v\n", cmab.RetryConfig) + + // Base assertions + 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", `{ + "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(" 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_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) + + // 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 +543,26 @@ 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", `{ + "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 +578,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 +679,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..6dc98b31 100644 --- a/config.yaml +++ b/config.yaml @@ -262,3 +262,28 @@ synchronization: datafile: enable: false default: "redis" + +## +## cmab: Contextual Multi-Armed Bandit configuration +## +cmab: + ## 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 + ## 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..9a90f08a 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,21 @@ func NewDefaultConfig() *AgentConfig { Default: "redis", }, }, + CMAB: CMABConfig{ + RequestTimeout: 10 * time.Second, + Cache: map[string]interface{}{ + "type": "memory", + "size": 1000, + "ttl": "30m", + }, + RetryConfig: map[string]interface{}{ + "maxRetries": 3, + "initialBackoff": "100ms", + "maxBackoff": "10s", + "backoffMultiplier": 2.0, + }, + }, } - return &config } @@ -162,6 +175,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 @@ -215,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 @@ -387,3 +402,15 @@ 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 { + // 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..1f23eed0 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,22 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, 0, conf.Runtime.BlockProfileRate) assert.Equal(t, 0, conf.Runtime.MutexProfileFraction) + + // CMAB configuration + 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"]) + + // 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 +249,23 @@ 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.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"]) + + // 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"]) +} 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" )