diff --git a/README.md b/README.md index 15ff2e36..62a43d93 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ Below is a comprehensive list of available configuration properties. | 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.predictionEndpoint | OPTIMIZELY_CMAB_PREDICTIONENDPOINT | URL template for CMAB prediction API with %s placeholder for experimentId. Default: https://prediction.cmab.optimizely.com/predict/%s | | 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 | @@ -150,6 +151,7 @@ Below is a comprehensive list of available configuration properties. ```json { "requestTimeout": "5s", + "predictionEndpoint": "https://prediction.cmab.optimizely.com/predict/%s", "cache": { "type": "memory", "size": 2000, diff --git a/cmd/optimizely/main.go b/cmd/optimizely/main.go index a3aa53cc..731d209b 100644 --- a/cmd/optimizely/main.go +++ b/cmd/optimizely/main.go @@ -48,6 +48,7 @@ import ( "github.com/optimizely/agent/pkg/optimizely" "github.com/optimizely/agent/pkg/routers" "github.com/optimizely/agent/pkg/server" + _ "github.com/optimizely/agent/plugins/cmabcache/all" // Initiate the loading of the cmabCache plugins _ "github.com/optimizely/agent/plugins/interceptors/all" // Initiate the loading of the userprofileservice plugins _ "github.com/optimizely/agent/plugins/odpcache/all" // Initiate the loading of the odpCache plugins _ "github.com/optimizely/agent/plugins/userprofileservice/all" // Initiate the loading of the interceptor plugins @@ -109,81 +110,59 @@ 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 { + // Check for complete CMAB configuration first (now under client.cmab) + if cmab := v.GetStringMap("client.cmab"); len(cmab) > 0 { if timeout, ok := cmab["requestTimeout"].(string); ok { if duration, err := time.ParseDuration(timeout); err == nil { - conf.CMAB.RequestTimeout = duration + conf.Client.CMAB.RequestTimeout = duration } } if cache, ok := cmab["cache"].(map[string]interface{}); ok { - if cacheType, ok := cache["type"].(string); ok { - conf.CMAB.Cache.Type = cacheType - } - if cacheSize, ok := cache["size"].(float64); ok { - conf.CMAB.Cache.Size = int(cacheSize) - } - if cacheTTL, ok := cache["ttl"].(string); ok { - if duration, err := time.ParseDuration(cacheTTL); err == nil { - conf.CMAB.Cache.TTL = duration - } - } + conf.Client.CMAB.Cache = cache } if retryConfig, ok := cmab["retryConfig"].(map[string]interface{}); ok { if maxRetries, ok := retryConfig["maxRetries"].(float64); ok { - conf.CMAB.RetryConfig.MaxRetries = int(maxRetries) + conf.Client.CMAB.RetryConfig.MaxRetries = int(maxRetries) } if initialBackoff, ok := retryConfig["initialBackoff"].(string); ok { if duration, err := time.ParseDuration(initialBackoff); err == nil { - conf.CMAB.RetryConfig.InitialBackoff = duration + conf.Client.CMAB.RetryConfig.InitialBackoff = duration } } if maxBackoff, ok := retryConfig["maxBackoff"].(string); ok { if duration, err := time.ParseDuration(maxBackoff); err == nil { - conf.CMAB.RetryConfig.MaxBackoff = duration + conf.Client.CMAB.RetryConfig.MaxBackoff = duration } } if backoffMultiplier, ok := retryConfig["backoffMultiplier"].(float64); ok { - conf.CMAB.RetryConfig.BackoffMultiplier = backoffMultiplier + conf.Client.CMAB.RetryConfig.BackoffMultiplier = backoffMultiplier } } } // Check for individual map sections - if cmabCache := v.GetStringMap("cmab.cache"); len(cmabCache) > 0 { - if cacheType, ok := cmabCache["type"].(string); ok { - conf.CMAB.Cache.Type = cacheType - } - if cacheSize, ok := cmabCache["size"].(int); ok { - conf.CMAB.Cache.Size = cacheSize - } else if cacheSize, ok := cmabCache["size"].(float64); ok { - conf.CMAB.Cache.Size = int(cacheSize) - } - if cacheTTL, ok := cmabCache["ttl"].(string); ok { - if duration, err := time.ParseDuration(cacheTTL); err == nil { - conf.CMAB.Cache.TTL = duration - } - } + if cmabCache := v.GetStringMap("client.cmab.cache"); len(cmabCache) > 0 { + conf.Client.CMAB.Cache = cmabCache } - if cmabRetryConfig := v.GetStringMap("cmab.retryConfig"); len(cmabRetryConfig) > 0 { + if cmabRetryConfig := v.GetStringMap("client.cmab.retryConfig"); len(cmabRetryConfig) > 0 { if maxRetries, ok := cmabRetryConfig["maxRetries"].(int); ok { - conf.CMAB.RetryConfig.MaxRetries = maxRetries + conf.Client.CMAB.RetryConfig.MaxRetries = maxRetries } else if maxRetries, ok := cmabRetryConfig["maxRetries"].(float64); ok { - conf.CMAB.RetryConfig.MaxRetries = int(maxRetries) + conf.Client.CMAB.RetryConfig.MaxRetries = int(maxRetries) } if initialBackoff, ok := cmabRetryConfig["initialBackoff"].(string); ok { if duration, err := time.ParseDuration(initialBackoff); err == nil { - conf.CMAB.RetryConfig.InitialBackoff = duration + conf.Client.CMAB.RetryConfig.InitialBackoff = duration } } if maxBackoff, ok := cmabRetryConfig["maxBackoff"].(string); ok { if duration, err := time.ParseDuration(maxBackoff); err == nil { - conf.CMAB.RetryConfig.MaxBackoff = duration + conf.Client.CMAB.RetryConfig.MaxBackoff = duration } } if backoffMultiplier, ok := cmabRetryConfig["backoffMultiplier"].(float64); ok { - conf.CMAB.RetryConfig.BackoffMultiplier = backoffMultiplier + conf.Client.CMAB.RetryConfig.BackoffMultiplier = backoffMultiplier } } diff --git a/cmd/optimizely/main_test.go b/cmd/optimizely/main_test.go index d5f929a9..558bb4ce 100644 --- a/cmd/optimizely/main_test.go +++ b/cmd/optimizely/main_test.go @@ -189,11 +189,20 @@ func assertCMAB(t *testing.T, cmab config.CMABConfig) { // Base assertions assert.Equal(t, 15*time.Second, cmab.RequestTimeout) - // Check cache configuration + // Check cache configuration (now a map[string]interface{}) cache := cmab.Cache - assert.Equal(t, "redis", cache.Type) - assert.Equal(t, 2000, cache.Size) - assert.Equal(t, 45*time.Minute, cache.TTL) + assert.NotNil(t, cache) + assert.Equal(t, "redis", cache["default"]) + + // Check services configuration + if services, ok := cache["services"].(map[string]interface{}); ok { + if redisConfig, ok := services["redis"].(map[string]interface{}); ok { + // Redis config should have host, database, and timeout fields + assert.NotNil(t, redisConfig["host"]) + assert.NotNil(t, redisConfig["database"]) + assert.NotNil(t, redisConfig["timeout"]) + } + } // Check retry configuration retry := cmab.RetryConfig @@ -204,12 +213,17 @@ func assertCMAB(t *testing.T, cmab config.CMABConfig) { } func TestCMABEnvDebug(t *testing.T) { - _ = os.Setenv("OPTIMIZELY_CMAB", `{ + _ = os.Setenv("OPTIMIZELY_CLIENT_CMAB", `{ "requestTimeout": "15s", "cache": { - "type": "redis", - "size": 2000, - "ttl": "45m" + "default": "redis", + "services": { + "redis": { + "host": "localhost:6379", + "database": 0, + "timeout": "45m" + } + } }, "retryConfig": { "maxRetries": 5, @@ -231,40 +245,87 @@ func TestCMABEnvDebug(t *testing.T) { // 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) + fmt.Printf(" RequestTimeout: %v\n", conf.Client.CMAB.RequestTimeout) + fmt.Printf(" Cache: %+v\n", conf.Client.CMAB.Cache) + fmt.Printf(" RetryConfig: %+v\n", conf.Client.CMAB.RetryConfig) // Call assertCMAB - assertCMAB(t, conf.CMAB) + assertCMAB(t, conf.Client.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") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE") + os.Unsetenv("OPTIMIZELY_CLIENT_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}`) + // Note: Cache is now a service-based map config + _ = os.Setenv("OPTIMIZELY_CLIENT_CMAB_CACHE", `{"default": "redis", "services": {"redis": {"host": "localhost:6379", "database": 0}}}`) + _ = os.Setenv("OPTIMIZELY_CLIENT_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, 3000, conf.CMAB.Cache.Size) + // Cache assertions (cache is now map[string]interface{}) + assert.NotNil(t, conf.Client.CMAB.Cache) + if defaultCache, ok := conf.Client.CMAB.Cache["default"].(string); ok { + assert.Equal(t, "redis", defaultCache) + } // RetryConfig assertions - assert.Equal(t, 10, conf.CMAB.RetryConfig.MaxRetries) + assert.Equal(t, 10, conf.Client.CMAB.RetryConfig.MaxRetries) // Clean up - os.Unsetenv("OPTIMIZELY_CMAB") - os.Unsetenv("OPTIMIZELY_CMAB_CACHE") - os.Unsetenv("OPTIMIZELY_CMAB_RETRYCONFIG") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_RETRYCONFIG") +} + +func TestCMABRetryConfigAllFields(t *testing.T) { + // Clean any existing environment variables + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_RETRYCONFIG") + + // Set all retry config fields via CMAB_RETRYCONFIG to cover lines 154-165 + _ = os.Setenv("OPTIMIZELY_CLIENT_CMAB_RETRYCONFIG", `{ + "maxRetries": 5, + "initialBackoff": "500ms", + "maxBackoff": "45s", + "backoffMultiplier": 2.5 + }`) + + defer func() { + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_RETRYCONFIG") + }() + + v := viper.New() + assert.NoError(t, initConfig(v)) + conf := loadConfig(v) + + // Verify all retry config fields were parsed correctly + assert.Equal(t, 5, conf.Client.CMAB.RetryConfig.MaxRetries) + assert.Equal(t, 500*time.Millisecond, conf.Client.CMAB.RetryConfig.InitialBackoff) + assert.Equal(t, 45*time.Second, conf.Client.CMAB.RetryConfig.MaxBackoff) + assert.Equal(t, 2.5, conf.Client.CMAB.RetryConfig.BackoffMultiplier) +} + +func TestCMABRetryConfigIntMaxRetries(t *testing.T) { + // Test the int type path for maxRetries (line 150) by using viper's Set method + // which will preserve the int type instead of converting to float64 + v := viper.New() + assert.NoError(t, initConfig(v)) + + // Set via viper directly to ensure it's an int, not float64 + v.Set("client.cmab.retryConfig.maxRetries", 7) + + conf := loadConfig(v) + + // Verify maxRetries was parsed as int + assert.Equal(t, 7, conf.Client.CMAB.RetryConfig.MaxRetries) } func TestViperYaml(t *testing.T) { @@ -481,12 +542,17 @@ 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", `{ + _ = os.Setenv("OPTIMIZELY_CLIENT_CMAB", `{ "requestTimeout": "15s", "cache": { - "type": "redis", - "size": 2000, - "ttl": "45m" + "default": "redis", + "services": { + "redis": { + "host": "localhost:6379", + "database": 0, + "timeout": "45m" + } + } }, "retryConfig": { "maxRetries": 5, @@ -511,7 +577,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) + assertCMAB(t, actual.Client.CMAB) } func TestLoggingWithIncludeSdkKey(t *testing.T) { @@ -615,28 +681,32 @@ 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") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE_TYPE") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE_SIZE") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE_TTL") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE_REDIS_HOST") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE_REDIS_PASSWORD") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE_REDIS_DATABASE") - // Set complex JSON environment variable for CMAB cache - _ = os.Setenv("OPTIMIZELY_CMAB_CACHE", `{"type":"redis","size":5000,"ttl":"3h"}`) + // Set complex JSON environment variable for CMAB cache (using new service-based format) + _ = os.Setenv("OPTIMIZELY_CLIENT_CMAB_CACHE", `{"default":"redis","services":{"redis":{"host":"localhost:6379","database":0,"timeout":"3h"}}}`) defer func() { // Clean up - os.Unsetenv("OPTIMIZELY_CMAB_CACHE") + os.Unsetenv("OPTIMIZELY_CLIENT_CMAB_CACHE") }() v := viper.New() assert.NoError(t, initConfig(v)) actual := loadConfig(v) - // Test cache settings from JSON environment variable - cache := actual.CMAB.Cache - assert.Equal(t, "redis", cache.Type) - assert.Equal(t, 5000, cache.Size) - assert.Equal(t, 3*time.Hour, cache.TTL) + // Test cache settings from JSON environment variable (cache is now map[string]interface{}) + cache := actual.Client.CMAB.Cache + assert.NotNil(t, cache) + if defaultCache, ok := cache["default"].(string); ok { + assert.Equal(t, "redis", defaultCache) + } + if services, ok := cache["services"].(map[string]interface{}); ok { + assert.NotNil(t, services["redis"]) + } } diff --git a/config.yaml b/config.yaml index 283d2890..a696a5d0 100644 --- a/config.yaml +++ b/config.yaml @@ -213,15 +213,51 @@ client: segmentsCache: default: "in-memory" services: - in-memory: + in-memory: size: 10000 timeout: 600s - # redis: + # redis: # host: "localhost:6379" # password: "" # database: 0 # timeout: 0s - + + ## Contextual Multi-Armed Bandit configuration + cmab: + ## timeout for CMAB API requests + requestTimeout: 10s + ## URL template for CMAB prediction API with %s placeholder for experimentId + predictionEndpoint: "https://prediction.cmab.optimizely.com/predict/%s" + ## CMAB cache configuration + ## Supports both in-memory (single instance) and Redis (multi-instance) caching + cache: + ## default cache service to use + default: "in-memory" + services: + ## in-memory cache (fast, isolated per Agent instance) + in-memory: + ## maximum number of entries for in-memory cache + size: 10000 + ## time-to-live for cached decisions + timeout: 30m + ## Redis cache (shared across multiple Agent instances) + ## Uncomment and configure for multi-instance deployments + # redis: + # host: "localhost:6379" + # password: "" + # database: 0 + # timeout: 30m + ## retry configuration for CMAB API requests + retryConfig: + ## maximum number of retry attempts (in addition to the initial request) + ## maxRetries: 1 means up to 2 total attempts (1 initial + 1 retry) + maxRetries: 1 + ## initial backoff duration + initialBackoff: 100ms + ## maximum backoff duration + maxBackoff: 10s + ## multiplier for exponential backoff + backoffMultiplier: 2.0 ## ## optimizely runtime configuration can be used for debugging and profiling the go runtime. @@ -266,28 +302,3 @@ 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 ed9eb646..307acc8c 100644 --- a/config/config.go +++ b/config/config.go @@ -102,6 +102,25 @@ func NewDefaultConfig() *AgentConfig { }, }, }, + CMAB: CMABConfig{ + RequestTimeout: 10 * time.Second, + PredictionEndpoint: "https://prediction.cmab.optimizely.com/predict/%s", + Cache: CMABCacheConfig{ + "default": "in-memory", + "services": map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": 10000, + "timeout": "30m", + }, + }, + }, + RetryConfig: CMABRetryConfig{ + MaxRetries: 1, + InitialBackoff: 100 * time.Millisecond, + MaxBackoff: 10 * time.Second, + BackoffMultiplier: 2.0, + }, + }, }, Runtime: RuntimeConfig{ BlockProfileRate: 0, // 0 is disabled @@ -140,20 +159,6 @@ func NewDefaultConfig() *AgentConfig { Default: "redis", }, }, - CMAB: CMABConfig{ - RequestTimeout: 10 * time.Second, - Cache: CMABCacheConfig{ - Type: "memory", - Size: 1000, - TTL: 30 * time.Minute, - }, - RetryConfig: CMABRetryConfig{ - MaxRetries: 3, - InitialBackoff: 100 * time.Millisecond, - MaxBackoff: 10 * time.Second, - BackoffMultiplier: 2.0, - }, - }, } return &config } @@ -175,7 +180,6 @@ 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 @@ -408,6 +412,9 @@ type CMABConfig struct { // RequestTimeout is the timeout for CMAB API requests RequestTimeout time.Duration `json:"requestTimeout"` + // PredictionEndpoint is the URL template for CMAB prediction API with %s placeholder for experimentId + PredictionEndpoint string `json:"predictionEndpoint"` + // Cache configuration Cache CMABCacheConfig `json:"cache"` @@ -415,15 +422,8 @@ type CMABConfig struct { RetryConfig CMABRetryConfig `json:"retryConfig"` } -// CMABCacheConfig holds the CMAB cache configuration -type CMABCacheConfig struct { - // Type of cache (currently only "memory" is supported) - Type string `json:"type"` - // Size is the maximum number of entries for in-memory cache - Size int `json:"size"` - // TTL is the time-to-live for cached decisions - TTL time.Duration `json:"ttl"` -} +// CMABCacheConfig holds the CMAB cache configuration (service-based) +type CMABCacheConfig map[string]interface{} // CMABRetryConfig holds the CMAB retry configuration type CMABRetryConfig struct { diff --git a/config/config_test.go b/config/config_test.go index eb0df6fb..f4f0da60 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -101,17 +101,20 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, 0, conf.Runtime.MutexProfileFraction) // CMAB configuration - assert.Equal(t, 10*time.Second, conf.CMAB.RequestTimeout) + assert.Equal(t, 10*time.Second, conf.Client.CMAB.RequestTimeout) - // Test cache settings - cache := conf.CMAB.Cache - assert.Equal(t, "memory", cache.Type) - assert.Equal(t, 1000, cache.Size) - assert.Equal(t, 30*time.Minute, cache.TTL) + // Test cache settings (cache is now map[string]interface{}) + assert.Equal(t, "in-memory", conf.Client.CMAB.Cache["default"]) + assert.Equal(t, map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": 10000, + "timeout": "30m", + }, + }, conf.Client.CMAB.Cache["services"]) // Test retry settings - retry := conf.CMAB.RetryConfig - assert.Equal(t, 3, retry.MaxRetries) + retry := conf.Client.CMAB.RetryConfig + assert.Equal(t, 1, retry.MaxRetries) assert.Equal(t, 100*time.Millisecond, retry.InitialBackoff) assert.Equal(t, 10*time.Second, retry.MaxBackoff) assert.Equal(t, 2.0, retry.BackoffMultiplier) @@ -254,17 +257,20 @@ func TestDefaultCMABConfig(t *testing.T) { conf := NewDefaultConfig() // Test default values - assert.Equal(t, 10*time.Second, conf.CMAB.RequestTimeout) + assert.Equal(t, 10*time.Second, conf.Client.CMAB.RequestTimeout) - // Test default cache settings - cache := conf.CMAB.Cache - assert.Equal(t, "memory", cache.Type) - assert.Equal(t, 1000, cache.Size) - assert.Equal(t, 30*time.Minute, cache.TTL) + // Test default cache settings (cache is now map[string]interface{}) + assert.Equal(t, "in-memory", conf.Client.CMAB.Cache["default"]) + assert.Equal(t, map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": 10000, + "timeout": "30m", + }, + }, conf.Client.CMAB.Cache["services"]) // Test default retry settings - retry := conf.CMAB.RetryConfig - assert.Equal(t, 3, retry.MaxRetries) + retry := conf.Client.CMAB.RetryConfig + assert.Equal(t, 1, retry.MaxRetries) assert.Equal(t, 100*time.Millisecond, retry.InitialBackoff) assert.Equal(t, 10*time.Second, retry.MaxBackoff) assert.Equal(t, 2.0, retry.BackoffMultiplier) diff --git a/pkg/optimizely/cache.go b/pkg/optimizely/cache.go index 64f3809f..1903571c 100644 --- a/pkg/optimizely/cache.go +++ b/pkg/optimizely/cache.go @@ -32,9 +32,10 @@ import ( "github.com/optimizely/agent/config" "github.com/optimizely/agent/pkg/syncer" + "github.com/optimizely/agent/plugins/cmabcache" "github.com/optimizely/agent/plugins/odpcache" "github.com/optimizely/agent/plugins/userprofileservice" - odpCachePkg "github.com/optimizely/go-sdk/v2/pkg/cache" + cachePkg "github.com/optimizely/go-sdk/v2/pkg/cache" "github.com/optimizely/go-sdk/v2/pkg/client" "github.com/optimizely/go-sdk/v2/pkg/cmab" sdkconfig "github.com/optimizely/go-sdk/v2/pkg/config" @@ -52,6 +53,7 @@ import ( const ( userProfileServicePlugin = "UserProfileService" odpCachePlugin = "ODP Cache" + cmabCachePlugin = "CMAB Cache" ) // OptlyCache implements the Cache interface backed by a concurrent map. @@ -61,6 +63,7 @@ type OptlyCache struct { optlyMap cmap.ConcurrentMap userProfileServiceMap cmap.ConcurrentMap odpCacheMap cmap.ConcurrentMap + cmabCacheMap cmap.ConcurrentMap ctx context.Context wg sync.WaitGroup } @@ -75,13 +78,15 @@ func NewCache(ctx context.Context, conf config.AgentConfig, metricsRegistry *Met userProfileServiceMap := cmap.New() odpCacheMap := cmap.New() + cmabCacheMap := cmap.New() cache := &OptlyCache{ ctx: ctx, wg: sync.WaitGroup{}, - loader: defaultLoader(conf, metricsRegistry, tracer, userProfileServiceMap, odpCacheMap, cmLoader, event.NewBatchEventProcessor), + loader: defaultLoader(conf, metricsRegistry, tracer, userProfileServiceMap, odpCacheMap, cmabCacheMap, cmLoader, event.NewBatchEventProcessor), optlyMap: cmap.New(), userProfileServiceMap: userProfileServiceMap, odpCacheMap: odpCacheMap, + cmabCacheMap: cmabCacheMap, } return cache @@ -155,6 +160,11 @@ func (c *OptlyCache) SetODPCache(sdkKey, odpCache string) { c.odpCacheMap.SetIfAbsent(sdkKey, odpCache) } +// SetCMABCache sets CMAB cache for the given sdkKey +func (c *OptlyCache) SetCMABCache(sdkKey, cmabCache string) { + c.cmabCacheMap.SetIfAbsent(sdkKey, cmabCache) +} + // Wait for all optimizely clients to gracefully shutdown func (c *OptlyCache) Wait() { c.wg.Wait() @@ -178,6 +188,7 @@ func defaultLoader( tracer trace.Tracer, userProfileServiceMap cmap.ConcurrentMap, odpCacheMap cmap.ConcurrentMap, + cmabCacheMap cmap.ConcurrentMap, pcFactory func(sdkKey string, options ...sdkconfig.OptionFunc) SyncedConfigManager, bpFactory func(options ...event.BPOptionConfig) *event.BatchEventProcessor) func(clientKey string) (*OptlyClient, error) { clientConf := agentConf.Client @@ -276,12 +287,12 @@ func defaultLoader( } } - var clientODPCache odpCachePkg.Cache + var clientODPCache cachePkg.Cache var rawODPCache = getServiceWithType(odpCachePlugin, sdkKey, odpCacheMap, clientConf.ODP.SegmentsCache) // Check if odp cache was provided by user if rawODPCache != nil { // convert odpCache to Cache interface - if convertedODPCache, ok := rawODPCache.(odpCachePkg.Cache); ok && convertedODPCache != nil { + if convertedODPCache, ok := rawODPCache.(cachePkg.Cache); ok && convertedODPCache != nil { clientODPCache = convertedODPCache } } @@ -314,29 +325,32 @@ func defaultLoader( ) clientOptions = append(clientOptions, client.WithOdpManager(odpManager)) - // Configure CMAB prediction endpoint from environment variable - // This allows FSC tests to override the endpoint by setting OPTIMIZELY_CMAB_PREDICTIONENDPOINT + // Configure CMAB prediction endpoint with priority: env var > config > default + // Environment variable allows test/runtime overrides if cmabEndpoint := os.Getenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT"); cmabEndpoint != "" { - // Set the global variable that go-sdk uses (FSC already includes the /%s format) + // Environment variable takes highest priority cmab.CMABPredictionEndpoint = cmabEndpoint - log.Info().Str("endpoint", cmabEndpoint).Msg("Using custom CMAB prediction endpoint") - } - - // Parse CMAB cache configuration - cacheSize := clientConf.CMAB.Cache.Size - if cacheSize == 0 { - cacheSize = cmab.DefaultCacheSize + log.Info().Str("endpoint", cmabEndpoint).Str("source", "environment").Msg("Using CMAB prediction endpoint") + } else if clientConf.CMAB.PredictionEndpoint != "" { + // Use config value if environment variable not set + cmab.CMABPredictionEndpoint = clientConf.CMAB.PredictionEndpoint + log.Info().Str("endpoint", clientConf.CMAB.PredictionEndpoint).Str("source", "config").Msg("Using CMAB prediction endpoint") } - cacheTTL := clientConf.CMAB.Cache.TTL - if cacheTTL == 0 { - cacheTTL = cmab.DefaultCacheTTL + // Get CMAB cache from service configuration + var clientCMABCache cachePkg.CacheWithRemove + var rawCMABCache = getServiceWithType(cmabCachePlugin, sdkKey, cmabCacheMap, clientConf.CMAB.Cache) + // Check if CMAB cache was provided by user + if rawCMABCache != nil { + // convert cmabCache to CacheWithRemove interface + if convertedCMABCache, ok := rawCMABCache.(cachePkg.CacheWithRemove); ok && convertedCMABCache != nil { + clientCMABCache = convertedCMABCache + } } - // Create CMAB config using client API (RetryConfig now handled internally by go-sdk) + // Create CMAB config using client API with custom cache cmabConfig := client.CmabConfig{ - CacheSize: cacheSize, - CacheTTL: cacheTTL, + Cache: clientCMABCache, HTTPTimeout: clientConf.CMAB.RequestTimeout, } @@ -366,6 +380,10 @@ func getServiceWithType(serviceType, sdkKey string, serviceMap cmap.ConcurrentMa if odpCreator, ok := odpcache.Creators[serviceName]; ok { serviceInstance = odpCreator() } + case cmabCachePlugin: + if cmabCreator, ok := cmabcache.Creators[serviceName]; ok && cmabCreator != nil { + serviceInstance = cmabCreator() + } default: } diff --git a/pkg/optimizely/cache_test.go b/pkg/optimizely/cache_test.go index 529bc697..5d7ac3bf 100644 --- a/pkg/optimizely/cache_test.go +++ b/pkg/optimizely/cache_test.go @@ -31,11 +31,14 @@ import ( "github.com/optimizely/agent/config" "github.com/optimizely/agent/pkg/metrics" "github.com/optimizely/agent/pkg/optimizely/optimizelytest" + "github.com/optimizely/agent/plugins/cmabcache" + cmabCacheServices "github.com/optimizely/agent/plugins/cmabcache/services" "github.com/optimizely/agent/plugins/odpcache" 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" + "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" @@ -57,6 +60,7 @@ func (suite *CacheTestSuite) SetupTest() { optlyMap: cmap.New(), userProfileServiceMap: cmap.New(), odpCacheMap: cmap.New(), + cmabCacheMap: cmap.New(), ctx: ctx, } @@ -394,11 +398,11 @@ var doOnce sync.Once // required since we only need to read datafile once type DefaultLoaderTestSuite struct { suite.Suite - registry *MetricsRegistry - bp *event.BatchEventProcessor - upsMap, odpCacheMap cmap.ConcurrentMap - bpFactory func(options ...event.BPOptionConfig) *event.BatchEventProcessor - pcFactory func(sdkKey string, options ...sdkconfig.OptionFunc) SyncedConfigManager + registry *MetricsRegistry + bp *event.BatchEventProcessor + upsMap, odpCacheMap, cmabCacheMap cmap.ConcurrentMap + bpFactory func(options ...event.BPOptionConfig) *event.BatchEventProcessor + pcFactory func(sdkKey string, options ...sdkconfig.OptionFunc) SyncedConfigManager } func (s *DefaultLoaderTestSuite) SetupTest() { @@ -408,6 +412,7 @@ func (s *DefaultLoaderTestSuite) SetupTest() { }) s.upsMap = cmap.New() s.odpCacheMap = cmap.New() + s.cmabCacheMap = cmap.New() s.bpFactory = func(options ...event.BPOptionConfig) *event.BatchEventProcessor { s.bp = event.NewBatchEventProcessor(options...) return s.bp @@ -446,7 +451,7 @@ func (s *DefaultLoaderTestSuite) TestDefaultLoader() { }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) @@ -500,7 +505,7 @@ func (s *DefaultLoaderTestSuite) TestUPSAndODPCacheHeaderOverridesDefaultKey() { tmpOdpCacheMap := cmap.New() tmpOdpCacheMap.Set("sdkkey", "in-memory") - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, tmpUPSMap, tmpOdpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, tmpUPSMap, tmpOdpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) @@ -564,7 +569,7 @@ func (s *DefaultLoaderTestSuite) TestFirstSaveConfiguresClientForRedisUPSAndODPC }}, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) s.NotNil(client.UserProfileService) @@ -622,7 +627,7 @@ func (s *DefaultLoaderTestSuite) TestFirstSaveConfiguresLRUCacheForInMemoryCache }}, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) s.NotNil(client.odpCache) @@ -653,7 +658,7 @@ func (s *DefaultLoaderTestSuite) TestHttpClientInitializesByDefaultRestUPS() { "rest": map[string]interface{}{}, }}, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) s.NotNil(client.UserProfileService) @@ -681,7 +686,7 @@ func (s *DefaultLoaderTestSuite) TestLoaderWithValidUserProfileServices() { }, }}, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) @@ -712,7 +717,7 @@ func (s *DefaultLoaderTestSuite) TestLoaderWithValidODPCache() { }}, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) @@ -735,7 +740,7 @@ func (s *DefaultLoaderTestSuite) TestLoaderWithEmptyUserProfileServices() { conf := config.ClientConfig{ UserProfileService: map[string]interface{}{}, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) s.Nil(client.UserProfileService) @@ -752,7 +757,7 @@ func (s *DefaultLoaderTestSuite) TestLoaderWithEmptyODPCache() { SegmentsCache: map[string]interface{}{}, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) s.Nil(client.odpCache) @@ -769,7 +774,7 @@ func (s *DefaultLoaderTestSuite) TestLoaderWithNoDefaultUserProfileServices() { "mock3": map[string]interface{}{}, }}, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) s.Nil(client.UserProfileService) @@ -788,7 +793,7 @@ func (s *DefaultLoaderTestSuite) TestLoaderWithNoDefaultODPCache() { }}, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) s.Nil(client.odpCache) @@ -823,322 +828,293 @@ func (s *DefaultLoaderTestSuite) TestDefaultRegexValidator() { } } -func (s *DefaultLoaderTestSuite) TestCMABConfigurationParsing() { +func (s *DefaultLoaderTestSuite) TestCMABEndpointEnvironmentVariable() { + // Save original value and restore after test + originalEndpoint := os.Getenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT") + defer func() { + if originalEndpoint == "" { + os.Unsetenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT") + } else { + os.Setenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT", originalEndpoint) + } + }() + + // Set custom endpoint + testEndpoint := "https://test.prediction.endpoint.com/predict/%s" + os.Setenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT", testEndpoint) + conf := config.ClientConfig{ SdkKeyRegex: "sdkkey", CMAB: config.CMABConfig{ RequestTimeout: 5 * time.Second, - Cache: config.CMABCacheConfig{ - Type: "memory", - Size: 500, - TTL: 15 * time.Minute, - }, - RetryConfig: config.CMABRetryConfig{ - MaxRetries: 5, - InitialBackoff: 200 * time.Millisecond, - MaxBackoff: 30 * time.Second, - BackoffMultiplier: 1.5, + Cache: map[string]interface{}{ + "default": "in-memory", + "services": map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": 10000, + "timeout": "30m", + }, + }, }, + RetryConfig: config.CMABRetryConfig{}, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, 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() { +func (s *DefaultLoaderTestSuite) TestCMABEndpointFromConfig() { + // Ensure environment variable is not set + originalEndpoint := os.Getenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT") + os.Unsetenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT") + defer func() { + if originalEndpoint != "" { + os.Setenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT", originalEndpoint) + } + }() + + // Set endpoint in config + configEndpoint := "https://config.prediction.endpoint.com/predict/%s" conf := config.ClientConfig{ SdkKeyRegex: "sdkkey", CMAB: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - // Empty cache and retry config should use defaults - Cache: config.CMABCacheConfig{}, + RequestTimeout: 5 * time.Second, + PredictionEndpoint: configEndpoint, + Cache: map[string]interface{}{ + "default": "in-memory", + "services": map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": 10000, + "timeout": "30m", + }, + }, + }, RetryConfig: config.CMABRetryConfig{}, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") s.NoError(err) s.NotNil(client) + // Verify that the CMAB prediction endpoint was set from config + s.Equal(configEndpoint, cmab.CMABPredictionEndpoint) } -func (s *DefaultLoaderTestSuite) TestCMABCacheConfigInvalidTTL() { +func (s *DefaultLoaderTestSuite) TestCMABEndpointEnvironmentOverridesConfig() { + // Save original value and restore after test + originalEndpoint := os.Getenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT") + defer func() { + if originalEndpoint == "" { + os.Unsetenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT") + } else { + os.Setenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT", originalEndpoint) + } + }() + + // Set both environment and config endpoints + envEndpoint := "https://env.prediction.endpoint.com/predict/%s" + configEndpoint := "https://config.prediction.endpoint.com/predict/%s" + os.Setenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT", envEndpoint) + conf := config.ClientConfig{ SdkKeyRegex: "sdkkey", CMAB: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - // Test with valid values since structured types prevent invalid input - Cache: config.CMABCacheConfig{ - Size: 1000, - TTL: 10 * time.Minute, + RequestTimeout: 5 * time.Second, + PredictionEndpoint: configEndpoint, + Cache: map[string]interface{}{ + "default": "in-memory", + "services": map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": 10000, + "timeout": "30m", + }, + }, }, RetryConfig: config.CMABRetryConfig{}, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) + loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.cmabCacheMap, s.pcFactory, s.bpFactory) client, err := loader("sdkkey") - s.NoError(err) // Should not error, just use defaults + s.NoError(err) s.NotNil(client) + // Verify that the environment variable takes priority + s.Equal(envEndpoint, cmab.CMABPredictionEndpoint) } -func (s *DefaultLoaderTestSuite) TestCMABCacheConfigWithValidStructuredTypes() { - conf := config.ClientConfig{ - SdkKeyRegex: "sdkkey", - CMAB: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - Cache: config.CMABCacheConfig{ - Type: "memory", - Size: 1000, - TTL: 15 * time.Minute, - }, - RetryConfig: config.CMABRetryConfig{ - MaxRetries: 3, - InitialBackoff: 100 * time.Millisecond, - MaxBackoff: 10 * time.Second, - BackoffMultiplier: 2.0, - }, - }, - } +func TestDefaultLoaderTestSuite(t *testing.T) { + suite.Run(t, new(DefaultLoaderTestSuite)) +} - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) - client, err := loader("sdkkey") +// CMAB Cache Tests - s.NoError(err) - s.NotNil(client) +func (suite *CacheTestSuite) TestSetCMABCache() { + suite.cache.SetCMABCache("one", "a") + + actual, ok := suite.cache.cmabCacheMap.Get("one") + suite.True(ok) + suite.Equal("a", actual) + + suite.cache.SetCMABCache("one", "b") + actual, ok = suite.cache.cmabCacheMap.Get("one") + suite.True(ok) + suite.Equal("a", actual) } -func (s *DefaultLoaderTestSuite) TestCMABRetryConfigWithValidDurations() { +func (suite *CacheTestSuite) TestGetCMABCacheJSONErrorCases() { conf := config.ClientConfig{ - SdkKeyRegex: "sdkkey", CMAB: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - Cache: config.CMABCacheConfig{}, - RetryConfig: config.CMABRetryConfig{ - MaxRetries: 3, - InitialBackoff: 200 * time.Millisecond, - MaxBackoff: 30 * time.Second, - BackoffMultiplier: 2.0, + Cache: map[string]interface{}{"services": map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": []string{"dummy"}, + }}, }, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) - client, err := loader("sdkkey") + // json unmarshal error case + suite.cache.SetCMABCache("one", "in-memory") + cmabCache := getServiceWithType(cmabCachePlugin, "one", suite.cache.cmabCacheMap, conf.CMAB.Cache) + suite.Nil(cmabCache) - s.NoError(err) - s.NotNil(client) + // json marshal error case + conf.CMAB.Cache = map[string]interface{}{"services": map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": make(chan int), + }}, + } + cmabCache = getServiceWithType(cmabCachePlugin, "one", suite.cache.cmabCacheMap, conf.CMAB.Cache) + suite.Nil(cmabCache) } -func (s *DefaultLoaderTestSuite) TestCMABConfigurationAllValidValues() { +func (suite *CacheTestSuite) TestNoCMABCacheProvidedInConfig() { conf := config.ClientConfig{ - SdkKeyRegex: "sdkkey", CMAB: config.CMABConfig{ - RequestTimeout: 10 * time.Second, - Cache: config.CMABCacheConfig{ - Type: "memory", - Size: 2000, - TTL: 45 * time.Minute, - }, - RetryConfig: config.CMABRetryConfig{ - MaxRetries: 10, - InitialBackoff: 500 * time.Millisecond, - MaxBackoff: 1 * time.Minute, - BackoffMultiplier: 3.0, - }, + Cache: 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) + suite.cache.SetCMABCache("one", "in-memory") + cmabCache := getServiceWithType(cmabCachePlugin, "one", suite.cache.cmabCacheMap, conf.CMAB.Cache) + suite.Nil(cmabCache) } -func (s *DefaultLoaderTestSuite) TestCMABWithZeroRequestTimeout() { +func (suite *CacheTestSuite) TestCMABCacheForSDKKeyNotProvidedInConfig() { conf := config.ClientConfig{ - SdkKeyRegex: "sdkkey", CMAB: config.CMABConfig{ - RequestTimeout: 0, // Zero timeout - Cache: config.CMABCacheConfig{}, - RetryConfig: config.CMABRetryConfig{}, + Cache: map[string]interface{}{"default": "in-memory", "services": map[string]interface{}{ + "in-memory": map[string]interface{}{ + "size": 0, + "timeout": "0s", + }}, + }, }, } - - 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) + suite.cache.SetCMABCache("one", "dummy") + cmabCache := getServiceWithType(cmabCachePlugin, "one", suite.cache.cmabCacheMap, conf.CMAB.Cache) + suite.Nil(cmabCache) } -func (s *DefaultLoaderTestSuite) TestCMABConfigurationEdgeCases() { - testCases := []struct { - name string - config config.CMABConfig - }{ - { - name: "Zero cache size", - config: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - Cache: config.CMABCacheConfig{ - Size: 0, - TTL: 30 * time.Minute, - }, - RetryConfig: config.CMABRetryConfig{}, - }, - }, - { - name: "Zero max retries", - config: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - Cache: config.CMABCacheConfig{}, - RetryConfig: config.CMABRetryConfig{ - MaxRetries: 0, - }, - }, - }, - { - name: "Very short TTL", - config: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - Cache: config.CMABCacheConfig{ - TTL: 1 * time.Millisecond, - }, - RetryConfig: config.CMABRetryConfig{}, - }, - }, - { - name: "Very long TTL", - config: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - Cache: config.CMABCacheConfig{ - TTL: 24 * time.Hour, - }, - RetryConfig: config.CMABRetryConfig{}, +func (suite *CacheTestSuite) TestNoCreatorAddedforCMABCache() { + conf := config.ClientConfig{ + CMAB: config.CMABConfig{ + Cache: map[string]interface{}{"default": "dummy", "services": map[string]interface{}{ + "dummy": map[string]interface{}{ + "size": 0, + "timeout": "0s", + }}, }, }, } - 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") + suite.cache.SetCMABCache("one", "dummy") + cmabCache := getServiceWithType(cmabCachePlugin, "one", suite.cache.cmabCacheMap, conf.CMAB.Cache) + suite.Nil(cmabCache) +} - 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 (suite *CacheTestSuite) TestNilCreatorAddedforCMABCache() { + cacheCreator := func() cache.Cache { + return nil } -} + cmabcache.Add("dummy", cacheCreator) -func (s *DefaultLoaderTestSuite) TestCMABConfigurationEmptyStructs() { conf := config.ClientConfig{ - SdkKeyRegex: "sdkkey", CMAB: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - Cache: config.CMABCacheConfig{}, // empty struct - RetryConfig: config.CMABRetryConfig{}, // empty struct + Cache: map[string]interface{}{"default": "dummy", "services": map[string]interface{}{ + "dummy": map[string]interface{}{ + "size": 0, + "timeout": "0s", + }}, + }, }, } - 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) + suite.cache.SetCMABCache("one", "dummy") + cmabCache := getServiceWithType(cmabCachePlugin, "one", suite.cache.cmabCacheMap, conf.CMAB.Cache) + suite.Nil(cmabCache) } -// Test that CMAB configuration doesn't interfere with existing functionality -func (s *DefaultLoaderTestSuite) TestCMABWithExistingServices() { +func (suite *CacheTestSuite) TestCMABInMemoryCacheCreator() { + inMemoryCacheCreator := func() cache.Cache { + return &cmabCacheServices.InMemoryCache{} + } + cmabcache.Add("in-memory-test", inMemoryCacheCreator) + 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: config.CMABCacheConfig{ - Type: "memory", - Size: 1000, - TTL: 30 * time.Minute, - }, - RetryConfig: config.CMABRetryConfig{ - MaxRetries: 5, + Cache: map[string]interface{}{"default": "in-memory-test", "services": map[string]interface{}{ + "in-memory-test": map[string]interface{}{ + "size": 10000, + "timeout": "600s", + }}, }, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) - client, err := loader("sdkkey") + suite.cache.SetCMABCache("one", "in-memory-test") + cmabCache := getServiceWithType(cmabCachePlugin, "one", suite.cache.cmabCacheMap, conf.CMAB.Cache) + suite.NotNil(cmabCache) - 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") + inMemoryCache, ok := cmabCache.(*cmabCacheServices.InMemoryCache) + suite.True(ok) + suite.Equal(10000, inMemoryCache.Size) + suite.Equal(600*time.Second, inMemoryCache.Timeout.Duration) } -func (s *DefaultLoaderTestSuite) TestCMABEndpointEnvironmentVariable() { - // Save original value and restore after test - originalEndpoint := os.Getenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT") - defer func() { - if originalEndpoint == "" { - os.Unsetenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT") - } else { - os.Setenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT", originalEndpoint) - } - }() - - // Set custom endpoint - testEndpoint := "https://test.prediction.endpoint.com/predict/%s" - os.Setenv("OPTIMIZELY_CMAB_PREDICTIONENDPOINT", testEndpoint) +func (suite *CacheTestSuite) TestCMABRedisCacheCreator() { + redisCacheCreator := func() cache.Cache { + return &cmabCacheServices.RedisCache{} + } + cmabcache.Add("redis-test", redisCacheCreator) conf := config.ClientConfig{ - SdkKeyRegex: "sdkkey", CMAB: config.CMABConfig{ - RequestTimeout: 5 * time.Second, - Cache: config.CMABCacheConfig{}, - RetryConfig: config.CMABRetryConfig{}, + Cache: map[string]interface{}{"default": "redis-test", "services": map[string]interface{}{ + "redis-test": map[string]interface{}{ + "host": "localhost:6379", + "password": "test-pass", + "database": 2, + "timeout": "300s", + }}, + }, }, } - loader := defaultLoader(config.AgentConfig{Client: conf}, s.registry, nil, s.upsMap, s.odpCacheMap, s.pcFactory, s.bpFactory) - client, err := loader("sdkkey") + suite.cache.SetCMABCache("one", "redis-test") + cmabCache := getServiceWithType(cmabCachePlugin, "one", suite.cache.cmabCacheMap, conf.CMAB.Cache) + suite.NotNil(cmabCache) - s.NoError(err) - s.NotNil(client) -} - -func TestDefaultLoaderTestSuite(t *testing.T) { - suite.Run(t, new(DefaultLoaderTestSuite)) + redisCache, ok := cmabCache.(*cmabCacheServices.RedisCache) + suite.True(ok) + suite.Equal("localhost:6379", redisCache.Address) + suite.Equal("test-pass", redisCache.Password) + suite.Equal(2, redisCache.Database) + suite.Equal(300*time.Second, redisCache.Timeout.Duration) } diff --git a/plugins/cmabcache/README.md b/plugins/cmabcache/README.md new file mode 100644 index 00000000..f2672b47 --- /dev/null +++ b/plugins/cmabcache/README.md @@ -0,0 +1,77 @@ +# CMAB Cache + +Use a CMAB Cache to cache Contextual Multi-Armed Bandit (CMAB) decisions fetched from the CMAB prediction service. + +## Out of Box Cache Usage + +1. To use the in-memory `CMABCache`, update the `config.yaml` as shown below: + +```yaml +## configure optional CMAB Cache +client: + cmab: + ## If no cache is defined (or no default is defined), we will use the default in-memory with default size and timeout + cache: + default: "in-memory" + services: + in-memory: + ## maximum number of entries for in-memory cache + size: 10000 + ## timeout after which the cached item will become invalid. + timeout: 30m +``` + +2. To use the redis `CMABCache`, update the `config.yaml` as shown below: + +```yaml +## configure optional CMAB Cache +client: + cmab: + ## If no cache is defined (or no default is defined), we will use the default in-memory with default size and timeout + cache: + default: "redis" + services: + redis: + host: "your_host" + password: "your_password" + database: 0 ## your database + timeout: 30m +``` + +## Custom CMABCache Implementation + +To implement a custom CMAB cache, the following steps need to be taken: + +1. Create a struct that implements the `cache.Cache` interface in `plugins/cmabcache/services`. + +2. Add an `init` method inside your CMABCache file as shown below: + +```go +func init() { + myCacheCreator := func() cache.Cache { + return &yourCacheStruct{ + } + } + cmabcache.Add("my_cache_name", myCacheCreator) +} +``` + +3. Update the `config.yaml` file with your `CMABCache` config as shown below: + +```yaml +## configure optional CMABCache +client: + cmab: + cache: + default: "my_cache_name" + services: + my_cache_name: + ## Add those parameters here that need to be mapped to the CMABCache + ## For example, if the CMAB cache struct has a json mappable property called `host` + ## it can be updated with value `abc.com` as shown + host: "abc.com" +``` + +- If a user has created multiple `CMABCache` services and wants to override the `default` `CMABCache` for a specific `sdkKey`, they can do so by providing the `CMABCache` name in the request Header `X-Optimizely-CMAB-Cache-Name`. + +- Whenever a request is made with a unique `sdkKey`, the agent node handling that request creates and caches a new `CMABCache`. To keep the `CMABCache` type consistent among all nodes in a cluster, it is recommended to send the request Header `X-Optimizely-CMAB-Cache-Name` in every request made. diff --git a/plugins/cmabcache/all/all.go b/plugins/cmabcache/all/all.go new file mode 100644 index 00000000..654caf8a --- /dev/null +++ b/plugins/cmabcache/all/all.go @@ -0,0 +1,24 @@ +/**************************************************************************** + * Copyright 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. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package all // +package all + +import ( + // Register your cmabCache here if it is created outside the cmabcache/services package + // Also, make sure your cmabCache calls `cmabcache.Add()` in its init() method + _ "github.com/optimizely/agent/plugins/cmabcache/services" +) diff --git a/plugins/cmabcache/registry.go b/plugins/cmabcache/registry.go new file mode 100644 index 00000000..43be9e86 --- /dev/null +++ b/plugins/cmabcache/registry.go @@ -0,0 +1,38 @@ +/**************************************************************************** + * Copyright 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. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package cmabcache // +package cmabcache + +import ( + "fmt" + + "github.com/optimizely/go-sdk/v2/pkg/cache" +) + +// Creator type defines a function for creating an instance of a Cache +type Creator func() cache.Cache + +// Creators stores the mapping of Creator against cmabCacheName +var Creators = map[string]Creator{} + +// Add registers a creator against cmabCacheName +func Add(cmabCacheName string, creator Creator) { + if _, ok := Creators[cmabCacheName]; ok { + panic(fmt.Sprintf("CMAB Cache with name %q already exists", cmabCacheName)) + } + Creators[cmabCacheName] = creator +} diff --git a/plugins/cmabcache/services/in_memory_cache.go b/plugins/cmabcache/services/in_memory_cache.go new file mode 100644 index 00000000..ff608930 --- /dev/null +++ b/plugins/cmabcache/services/in_memory_cache.go @@ -0,0 +1,74 @@ +/**************************************************************************** + * Copyright 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. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package services // +package services + +import ( + "github.com/optimizely/agent/plugins/cmabcache" + "github.com/optimizely/agent/plugins/utils" + "github.com/optimizely/go-sdk/v2/pkg/cache" +) + +// InMemoryCache represents the in-memory implementation of Cache interface +type InMemoryCache struct { + Size int `json:"size"` + Timeout utils.Duration `json:"timeout"` + *cache.LRUCache +} + +// Lookup is used to retrieve cached CMAB decisions +func (i *InMemoryCache) Lookup(key string) interface{} { + if i.LRUCache == nil { + i.initClient() + return nil + } + return i.LRUCache.Lookup(key) +} + +// Save is used to save CMAB decisions +func (i *InMemoryCache) Save(key string, value interface{}) { + if i.LRUCache == nil { + i.initClient() + } + i.LRUCache.Save(key, value) +} + +// Remove is used to remove a specific CMAB decision from cache +func (i *InMemoryCache) Remove(key string) { + if i.LRUCache == nil { + return + } + i.LRUCache.Remove(key) +} + +// Reset is used to reset all CMAB decisions +func (i *InMemoryCache) Reset() { + if i.LRUCache != nil { + i.LRUCache.Reset() + } +} + +func (i *InMemoryCache) initClient() { + i.LRUCache = cache.NewLRUCache(i.Size, i.Timeout.Duration) +} + +func init() { + inMemoryCacheCreator := func() cache.Cache { + return &InMemoryCache{} + } + cmabcache.Add("in-memory", inMemoryCacheCreator) +} diff --git a/plugins/cmabcache/services/in_memory_cache_test.go b/plugins/cmabcache/services/in_memory_cache_test.go new file mode 100644 index 00000000..4b8bfe42 --- /dev/null +++ b/plugins/cmabcache/services/in_memory_cache_test.go @@ -0,0 +1,128 @@ +/**************************************************************************** + * Copyright 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. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package services // +package services + +import ( + "testing" + "time" + + "github.com/optimizely/agent/plugins/utils" + "github.com/stretchr/testify/suite" +) + +type InMemoryCacheTestSuite struct { + suite.Suite + cache InMemoryCache +} + +func (im *InMemoryCacheTestSuite) SetupTest() { + im.cache = InMemoryCache{ + Size: 10000, + Timeout: utils.Duration{Duration: 600 * time.Second}, + } +} + +func (im *InMemoryCacheTestSuite) TestLookupInitializesCache() { + im.Nil(im.cache.LRUCache) + im.cache.Lookup("abc") + im.NotNil(im.cache.LRUCache) +} + +func (im *InMemoryCacheTestSuite) TestSaveInitializesCacheAndSaves() { + im.Nil(im.cache.LRUCache) + + // Test with CMAB decision object + decision := map[string]interface{}{ + "variationID": "variation_1", + "attributesHash": "hash123", + "cmabUUID": "uuid-456", + } + + im.cache.Save("user123:exp456", decision) + im.NotNil(im.cache.LRUCache) + + result := im.cache.Lookup("user123:exp456") + im.NotNil(result) + im.Equal(decision, result) +} + +func (im *InMemoryCacheTestSuite) TestLookupReturnsNilForMissingKey() { + im.cache.Save("key1", "value1") + im.Nil(im.cache.Lookup("nonexistent")) +} + +func (im *InMemoryCacheTestSuite) TestRemoveWithoutInitialization() { + im.Nil(im.cache.LRUCache) + im.cache.Remove("key1") + im.Nil(im.cache.LRUCache) +} + +func (im *InMemoryCacheTestSuite) TestRemoveDeletesKey() { + decision := map[string]interface{}{ + "variationID": "variation_1", + } + + im.cache.Save("user123:exp456", decision) + im.NotNil(im.cache.Lookup("user123:exp456")) + + im.cache.Remove("user123:exp456") + im.Nil(im.cache.Lookup("user123:exp456")) +} + +func (im *InMemoryCacheTestSuite) TestResetWithoutInitialization() { + im.Nil(im.cache.LRUCache) + im.cache.Reset() + im.Nil(im.cache.LRUCache) +} + +func (im *InMemoryCacheTestSuite) TestResetAfterSave() { + im.Nil(im.cache.LRUCache) + + im.cache.Save("key1", "value1") + im.cache.Save("key2", "value2") + im.NotNil(im.cache.LRUCache) + + im.Equal("value1", im.cache.Lookup("key1")) + im.Equal("value2", im.cache.Lookup("key2")) + + im.cache.Reset() + + im.Nil(im.cache.Lookup("key1")) + im.Nil(im.cache.Lookup("key2")) +} + +func (im *InMemoryCacheTestSuite) TestMultipleDecisions() { + decision1 := map[string]interface{}{ + "variationID": "var1", + "attributesHash": "hash1", + } + decision2 := map[string]interface{}{ + "variationID": "var2", + "attributesHash": "hash2", + } + + im.cache.Save("user1:exp1", decision1) + im.cache.Save("user2:exp1", decision2) + + im.Equal(decision1, im.cache.Lookup("user1:exp1")) + im.Equal(decision2, im.cache.Lookup("user2:exp1")) +} + +func TestInMemoryCacheTestSuite(t *testing.T) { + suite.Run(t, new(InMemoryCacheTestSuite)) +} diff --git a/plugins/cmabcache/services/redis_cache.go b/plugins/cmabcache/services/redis_cache.go new file mode 100644 index 00000000..6b70ef21 --- /dev/null +++ b/plugins/cmabcache/services/redis_cache.go @@ -0,0 +1,140 @@ +/**************************************************************************** + * Copyright 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. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package services // +package services + +import ( + "context" + "encoding/json" + + "github.com/go-redis/redis/v8" + "github.com/optimizely/agent/plugins/cmabcache" + "github.com/optimizely/agent/plugins/utils" + "github.com/optimizely/go-sdk/v2/pkg/cache" + "github.com/rs/zerolog/log" +) + +var ctx = context.Background() + +// RedisCache represents the redis implementation of Cache interface for CMAB +type RedisCache struct { + Client *redis.Client + Address string `json:"host"` + Password string `json:"password"` + Database int `json:"database"` + Timeout utils.Duration `json:"timeout"` +} + +// Lookup is used to retrieve cached CMAB decisions +func (r *RedisCache) Lookup(key string) interface{} { + // This is required in both lookup and save since an old redis instance can also be used + if r.Client == nil { + r.initClient() + } + + if key == "" { + return nil + } + + // Check if decision exists + result, getError := r.Client.Get(ctx, key).Result() + if getError != nil { + if getError != redis.Nil { + log.Error().Err(getError).Msg("Failed to get CMAB decision from Redis") + } + return nil + } + + // Unmarshal the cached decision + // The CMAB cache stores the decision object directly + var cachedDecision interface{} + err := json.Unmarshal([]byte(result), &cachedDecision) + if err != nil { + log.Error().Err(err).Msg("Failed to unmarshal CMAB decision from Redis") + return nil + } + return cachedDecision +} + +// Save is used to save CMAB decisions +func (r *RedisCache) Save(key string, value interface{}) { + // This is required in both lookup and save since an old redis instance can also be used + if r.Client == nil { + r.initClient() + } + + if key == "" { + return + } + + // Marshal the decision value + finalDecision, err := json.Marshal(value) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal CMAB decision for Redis") + return + } + + // Save to Redis with TTL + if setError := r.Client.Set(ctx, key, finalDecision, r.Timeout.Duration).Err(); setError != nil { + log.Error().Err(setError).Msg("Failed to save CMAB decision to Redis") + } +} + +// Remove is used to remove a specific CMAB decision from cache +func (r *RedisCache) Remove(key string) { + // This is required since remove can be called before lookup and save + if r.Client == nil { + r.initClient() + } + + if key == "" { + return + } + + if delError := r.Client.Del(ctx, key).Err(); delError != nil { + log.Error().Err(delError).Msg("Failed to remove CMAB decision from Redis") + } +} + +// Reset is used to reset all CMAB decisions +func (r *RedisCache) Reset() { + // This is required since reset can be called before lookup and save + if r.Client == nil { + r.initClient() + } + + if r.Client != nil { + if flushError := r.Client.FlushDB(ctx).Err(); flushError != nil { + log.Error().Err(flushError).Msg("Failed to flush CMAB cache in Redis") + } + } +} + +func (r *RedisCache) initClient() { + r.Client = redis.NewClient(&redis.Options{ + Addr: r.Address, + Password: r.Password, + DB: r.Database, + }) +} + +func init() { + redisCacheCreator := func() cache.Cache { + return &RedisCache{} + } + cmabcache.Add("redis", redisCacheCreator) +} diff --git a/plugins/cmabcache/services/redis_cache_test.go b/plugins/cmabcache/services/redis_cache_test.go new file mode 100644 index 00000000..b851cb65 --- /dev/null +++ b/plugins/cmabcache/services/redis_cache_test.go @@ -0,0 +1,102 @@ +/**************************************************************************** + * Copyright 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. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +// Package services // +package services + +import ( + "testing" + "time" + + "github.com/optimizely/agent/plugins/utils" + "github.com/stretchr/testify/suite" +) + +type RedisCacheTestSuite struct { + suite.Suite + cache RedisCache +} + +func (r *RedisCacheTestSuite) SetupTest() { + r.cache = RedisCache{ + Address: "invalid-redis-host:6379", + Password: "test-password", + Database: 1, + Timeout: utils.Duration{Duration: 100 * time.Second}, + } +} + +func (r *RedisCacheTestSuite) TestFirstSaveOrLookupConfiguresClient() { + r.Nil(r.cache.Client) + + // Should initialize redis client on first save call + decision := map[string]interface{}{ + "variationID": "var1", + } + r.cache.Save("key1", decision) + r.NotNil(r.cache.Client) + + r.cache.Client = nil + // Should initialize redis client on first lookup call + r.cache.Lookup("key1") + r.NotNil(r.cache.Client) +} + +func (r *RedisCacheTestSuite) TestLookupEmptyKey() { + r.Nil(r.cache.Lookup("")) +} + +func (r *RedisCacheTestSuite) TestSaveEmptyKey() { + r.cache.Save("", "value") + // Should not panic, client should be initialized + r.NotNil(r.cache.Client) +} + +func (r *RedisCacheTestSuite) TestLookupNotSavedKey() { + // This will fail to connect to Redis but shouldn't panic + r.Nil(r.cache.Lookup("nonexistent-key")) +} + +func (r *RedisCacheTestSuite) TestRemoveEmptyKey() { + r.cache.Remove("") + // Should not panic + r.NotNil(r.cache.Client) +} + +func (r *RedisCacheTestSuite) TestRemoveInitializesClient() { + r.Nil(r.cache.Client) + r.cache.Remove("key1") + r.NotNil(r.cache.Client) +} + +func (r *RedisCacheTestSuite) TestResetInitializesClient() { + r.Nil(r.cache.Client) + r.cache.Reset() + r.NotNil(r.cache.Client) +} + +func (r *RedisCacheTestSuite) TestClientConfiguration() { + r.cache.initClient() + + r.NotNil(r.cache.Client) + r.Equal("invalid-redis-host:6379", r.cache.Client.Options().Addr) + r.Equal("test-password", r.cache.Client.Options().Password) + r.Equal(1, r.cache.Client.Options().DB) +} + +func TestRedisCacheTestSuite(t *testing.T) { + suite.Run(t, new(RedisCacheTestSuite)) +}