diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj index 2ba52d48..f73e809c 100644 --- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj +++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj @@ -187,6 +187,12 @@ Cmab\DefaultCmabClient.cs + + Cmab\ICmabService.cs + + + Cmab\DefaultCmabService.cs + Cmab\CmabRetryConfig.cs diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs new file mode 100644 index 00000000..9dac9699 --- /dev/null +++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs @@ -0,0 +1,388 @@ +/* +* Copyright 2025, Optimizely +* +* 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. +*/ + +using System; +using System.Collections.Generic; +using Moq; +using NUnit.Framework; +using OptimizelySDK.Cmab; +using OptimizelySDK.Config; +using OptimizelySDK.Entity; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Logger; +using OptimizelySDK.Odp; +using OptimizelySDK.OptimizelyDecisions; +using AttributeEntity = OptimizelySDK.Entity.Attribute; + +namespace OptimizelySDK.Tests.CmabTests +{ + [TestFixture] + public class DefaultCmabServiceTest + { + private Mock _mockCmabClient; + private LruCache _cmabCache; + private DefaultCmabService _cmabService; + private ILogger _logger; + private ProjectConfig _config; + private Optimizely _optimizely; + + private const string TEST_RULE_ID = "exp1"; + private const string TEST_USER_ID = "user123"; + private const string AGE_ATTRIBUTE_ID = "66"; + private const string LOCATION_ATTRIBUTE_ID = "77"; + + [SetUp] + public void SetUp() + { + _mockCmabClient = new Mock(MockBehavior.Strict); + _logger = new NoOpLogger(); + _cmabCache = new LruCache(maxSize: 10, itemTimeout: TimeSpan.FromMinutes(5), logger: _logger); + _cmabService = new DefaultCmabService(_cmabCache, _mockCmabClient.Object, _logger); + + _config = DatafileProjectConfig.Create(TestData.Datafile, _logger, new NoOpErrorHandler()); + _optimizely = new Optimizely(TestData.Datafile, null, _logger, new NoOpErrorHandler()); + } + + [Test] + public void ReturnsDecisionFromCacheWhenHashMatches() + { + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); + var attributeMap = new Dictionary + { + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } + }; + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); + var filteredAttributes = new UserAttributes(new Dictionary { { "age", 25 } }); + var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID); + + _cmabCache.Save(cacheKey, new CmabCacheEntry + { + AttributesHash = DefaultCmabService.HashAttributes(filteredAttributes), + CmabUuid = "uuid-cached", + VariationId = "varA" + }); + + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + + Assert.IsNotNull(decision); + Assert.AreEqual("varA", decision.VariationId); + Assert.AreEqual("uuid-cached", decision.CmabUuid); + _mockCmabClient.Verify(c => c.FetchDecision(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void IgnoresCacheWhenOptionSpecified() + { + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); + var attributeMap = new Dictionary + { + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } + }; + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); + var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID); + + _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, + It.Is>(attrs => attrs != null && attrs.Count == 1 && attrs.ContainsKey("age") && (int)attrs["age"] == 25), + It.IsAny(), + It.IsAny())).Returns("varB"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, + new[] { OptimizelyDecideOption.IGNORE_CMAB_CACHE }); + + Assert.IsNotNull(decision); + Assert.AreEqual("varB", decision.VariationId); + Assert.IsNull(_cmabCache.Lookup(cacheKey)); + _mockCmabClient.VerifyAll(); + } + + [Test] + public void ResetsCacheWhenOptionSpecified() + { + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); + var attributeMap = new Dictionary + { + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } + }; + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); + var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID); + + _cmabCache.Save(cacheKey, new CmabCacheEntry + { + AttributesHash = "stale", + CmabUuid = "uuid-old", + VariationId = "varOld" + }); + + _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, + It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25), + It.IsAny(), + It.IsAny())).Returns("varNew"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, + new[] { OptimizelyDecideOption.RESET_CMAB_CACHE }); + + Assert.IsNotNull(decision); + Assert.AreEqual("varNew", decision.VariationId); + var cachedEntry = _cmabCache.Lookup(cacheKey); + Assert.IsNotNull(cachedEntry); + Assert.AreEqual("varNew", cachedEntry.VariationId); + Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid); + _mockCmabClient.VerifyAll(); + } + + [Test] + public void InvalidatesUserEntryWhenOptionSpecified() + { + var otherUserId = "other"; + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); + var attributeMap = new Dictionary + { + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } + }; + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); + + var targetKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID); + var otherKey = DefaultCmabService.GetCacheKey(otherUserId, TEST_RULE_ID); + + _cmabCache.Save(targetKey, new CmabCacheEntry + { + AttributesHash = "old_hash", + CmabUuid = "uuid-old", + VariationId = "varOld" + }); + _cmabCache.Save(otherKey, new CmabCacheEntry + { + AttributesHash = "other_hash", + CmabUuid = "uuid-other", + VariationId = "varOther" + }); + + _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, + It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25), + It.IsAny(), + It.IsAny())).Returns("varNew"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, + new[] { OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE }); + + Assert.IsNotNull(decision); + Assert.AreEqual("varNew", decision.VariationId); + var updatedEntry = _cmabCache.Lookup(targetKey); + Assert.IsNotNull(updatedEntry); + Assert.AreEqual(decision.CmabUuid, updatedEntry.CmabUuid); + Assert.AreEqual("varNew", updatedEntry.VariationId); + + var otherEntry = _cmabCache.Lookup(otherKey); + Assert.IsNotNull(otherEntry); + Assert.AreEqual("varOther", otherEntry.VariationId); + _mockCmabClient.VerifyAll(); + } + + [Test] + public void FetchesNewDecisionWhenHashDiffers() + { + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); + var attributeMap = new Dictionary + { + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } + }; + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); + + var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID); + _cmabCache.Save(cacheKey, new CmabCacheEntry + { + AttributesHash = "different_hash", + CmabUuid = "uuid-old", + VariationId = "varOld" + }); + + _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, + It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25), + It.IsAny(), + It.IsAny())).Returns("varUpdated"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + + Assert.IsNotNull(decision); + Assert.AreEqual("varUpdated", decision.VariationId); + var cachedEntry = _cmabCache.Lookup(cacheKey); + Assert.IsNotNull(cachedEntry); + Assert.AreEqual("varUpdated", cachedEntry.VariationId); + Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid); + _mockCmabClient.VerifyAll(); + } + + [Test] + public void FiltersAttributesBeforeCallingClient() + { + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID }); + var attributeMap = new Dictionary + { + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } }, + { LOCATION_ATTRIBUTE_ID, new AttributeEntity { Id = LOCATION_ATTRIBUTE_ID, Key = "location" } } + }; + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary + { + { "age", 25 }, + { "location", "USA" }, + { "extra", "value" } + }); + + _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, + It.Is>(attrs => attrs.Count == 2 && + (int)attrs["age"] == 25 && + (string)attrs["location"] == "USA" && + !attrs.ContainsKey("extra")), + It.IsAny(), + It.IsAny())).Returns("varFiltered"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + + Assert.IsNotNull(decision); + Assert.AreEqual("varFiltered", decision.VariationId); + _mockCmabClient.VerifyAll(); + } + + [Test] + public void HandlesMissingCmabConfiguration() + { + var experiment = CreateExperiment(TEST_RULE_ID, null); + var attributeMap = new Dictionary(); + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); + + _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, + It.Is>(attrs => attrs.Count == 0), + It.IsAny(), + It.IsAny())).Returns("varDefault"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + + Assert.IsNotNull(decision); + Assert.AreEqual("varDefault", decision.VariationId); + _mockCmabClient.VerifyAll(); + } + + [Test] + public void AttributeHashIsStableRegardlessOfOrder() + { + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID }); + var attributeMap = new Dictionary + { + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "a" } }, + { LOCATION_ATTRIBUTE_ID, new AttributeEntity { Id = LOCATION_ATTRIBUTE_ID, Key = "b" } } + }; + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + + var firstContext = CreateUserContext(TEST_USER_ID, new Dictionary + { + { "b", 2 }, + { "a", 1 } + }); + + _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns("varStable"); + + var firstDecision = _cmabService.GetDecision(projectConfig, firstContext, TEST_RULE_ID, null); + Assert.IsNotNull(firstDecision); + Assert.AreEqual("varStable", firstDecision.VariationId); + + var secondContext = CreateUserContext(TEST_USER_ID, new Dictionary + { + { "a", 1 }, + { "b", 2 } + }); + + var secondDecision = _cmabService.GetDecision(projectConfig, secondContext, TEST_RULE_ID, null); + + Assert.IsNotNull(secondDecision); + Assert.AreEqual("varStable", secondDecision.VariationId); + _mockCmabClient.Verify(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, + It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void UsesExpectedCacheKeyFormat() + { + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); + var attributeMap = new Dictionary + { + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } + }; + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); + + _mockCmabClient.Setup(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns("varKey"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + Assert.IsNotNull(decision); + Assert.AreEqual("varKey", decision.VariationId); + + var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID); + var cachedEntry = _cmabCache.Lookup(cacheKey); + Assert.IsNotNull(cachedEntry); + Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid); + } + + private OptimizelyUserContext CreateUserContext(string userId, IDictionary attributes) + { + var userContext = _optimizely.CreateUserContext(userId); + + foreach (var attr in attributes) + { + userContext.SetAttribute(attr.Key, attr.Value); + } + + return userContext; + } + + private static ProjectConfig CreateProjectConfig(string ruleId, Experiment experiment, + Dictionary attributeMap) + { + var mockConfig = new Mock(); + var experimentMap = new Dictionary(); + if (experiment != null) + { + experimentMap[ruleId] = experiment; + } + + mockConfig.SetupGet(c => c.ExperimentIdMap).Returns(experimentMap); + mockConfig.SetupGet(c => c.AttributeIdMap).Returns(attributeMap ?? new Dictionary()); + return mockConfig.Object; + } + + private static Experiment CreateExperiment(string ruleId, List attributeIds) + { + return new Experiment + { + Id = ruleId, + Cmab = attributeIds == null ? null : new Entity.Cmab(attributeIds) + }; + } + + } +} diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index 01469f77..1b0b882e 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -71,6 +71,7 @@ + diff --git a/OptimizelySDK/Cmab/DefaultCmabService.cs b/OptimizelySDK/Cmab/DefaultCmabService.cs new file mode 100644 index 00000000..2cdf18c3 --- /dev/null +++ b/OptimizelySDK/Cmab/DefaultCmabService.cs @@ -0,0 +1,261 @@ +/* +* Copyright 2025, Optimizely +* +* 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. +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using OptimizelySDK; +using OptimizelySDK.Entity; +using OptimizelySDK.Logger; +using OptimizelySDK.Odp; +using OptimizelySDK.OptimizelyDecisions; +using AttributeEntity = OptimizelySDK.Entity.Attribute; + +namespace OptimizelySDK.Cmab +{ + /// + /// Represents a CMAB decision response returned by the service. + /// + public class CmabDecision + { + /// + /// Initializes a new instance of the CmabDecision class. + /// + /// The variation ID assigned by the CMAB service. + /// The unique identifier for this CMAB decision. + public CmabDecision(string variationId, string cmabUuid) + { + VariationId = variationId; + CmabUuid = cmabUuid; + } + + /// + /// Gets the variation ID assigned by the CMAB service. + /// + public string VariationId { get; } + + /// + /// Gets the unique identifier for this CMAB decision. + /// + public string CmabUuid { get; } + } + + /// + /// Represents a cached CMAB decision entry. + /// + public class CmabCacheEntry + { + /// + /// Gets or sets the hash of the filtered attributes used for this decision. + /// + public string AttributesHash { get; set; } + + /// + /// Gets or sets the variation ID from the cached decision. + /// + public string VariationId { get; set; } + + /// + /// Gets or sets the CMAB UUID from the cached decision. + /// + public string CmabUuid { get; set; } + } + + /// + /// Default implementation of the CMAB decision service that handles caching and filtering. + /// Provides methods for retrieving CMAB decisions with intelligent caching based on user attributes. + /// + public class DefaultCmabService : ICmabService + { + private readonly LruCache _cmabCache; + private readonly ICmabClient _cmabClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the DefaultCmabService class. + /// + /// LRU cache for storing CMAB decisions. + /// Client for fetching decisions from the CMAB prediction service. + /// Optional logger for recording service operations. + public DefaultCmabService(LruCache cmabCache, + ICmabClient cmabClient, + ILogger logger = null) + { + _cmabCache = cmabCache; + _cmabClient = cmabClient; + _logger = logger ?? new NoOpLogger(); + } + + public CmabDecision GetDecision(ProjectConfig projectConfig, + OptimizelyUserContext userContext, + string ruleId, + OptimizelyDecideOption[] options = null) + { + var optionSet = options ?? new OptimizelyDecideOption[0]; + var filteredAttributes = FilterAttributes(projectConfig, userContext, ruleId); + + if (optionSet.Contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE)) + { + _logger.Log(LogLevel.DEBUG, "Ignoring CMAB cache."); + return FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes); + } + + if (optionSet.Contains(OptimizelyDecideOption.RESET_CMAB_CACHE)) + { + _logger.Log(LogLevel.DEBUG, "Resetting CMAB cache."); + _cmabCache.Reset(); + } + + var cacheKey = GetCacheKey(userContext.GetUserId(), ruleId); + + if (optionSet.Contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE)) + { + _logger.Log(LogLevel.DEBUG, "Invalidating user CMAB cache."); + _cmabCache.Remove(cacheKey); + } + + var cachedValue = _cmabCache.Lookup(cacheKey); + var attributesHash = HashAttributes(filteredAttributes); + + if (cachedValue != null) + { + if (string.Equals(cachedValue.AttributesHash, attributesHash, StringComparison.Ordinal)) + { + return new CmabDecision(cachedValue.VariationId, cachedValue.CmabUuid); + } + else + { + _cmabCache.Remove(cacheKey); + } + + } + + var cmabDecision = FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes); + + _cmabCache.Save(cacheKey, new CmabCacheEntry + { + AttributesHash = attributesHash, + VariationId = cmabDecision.VariationId, + CmabUuid = cmabDecision.CmabUuid, + }); + + return cmabDecision; + } + + /// + /// Fetches a new decision from the CMAB client and generates a unique UUID for tracking. + /// + /// The experiment/rule ID. + /// The user ID. + /// The filtered user attributes to send to the CMAB service. + /// A new CmabDecision with the assigned variation and generated UUID. + private CmabDecision FetchDecision(string ruleId, + string userId, + UserAttributes attributes) + { + var cmabUuid = Guid.NewGuid().ToString(); + var variationId = _cmabClient.FetchDecision(ruleId, userId, attributes, cmabUuid); + return new CmabDecision(variationId, cmabUuid); + } + + /// + /// Filters user attributes to include only those configured for the CMAB experiment. + /// + /// The project configuration containing attribute mappings. + /// The user context with all user attributes. + /// The experiment/rule ID to get CMAB attribute configuration for. + /// A UserAttributes object containing only the filtered attributes, or empty if no CMAB config exists. + /// + /// Only attributes specified in the experiment's CMAB configuration are included. + /// This ensures that cache invalidation is based only on relevant attributes. + /// + private UserAttributes FilterAttributes(ProjectConfig projectConfig, + OptimizelyUserContext userContext, + string ruleId) + { + var filtered = new UserAttributes(); + + if (projectConfig.ExperimentIdMap == null || + !projectConfig.ExperimentIdMap.TryGetValue(ruleId, out var experiment) || + experiment?.Cmab?.AttributeIds == null || + experiment.Cmab.AttributeIds.Count == 0) + { + return filtered; + } + + var userAttributes = userContext.GetAttributes() ?? new UserAttributes(); + var attributeIdMap = projectConfig.AttributeIdMap ?? new Dictionary(); + + foreach (var attributeId in experiment.Cmab.AttributeIds) + { + if (attributeIdMap.TryGetValue(attributeId, out var attribute) && + userAttributes.TryGetValue(attribute.Key, out var value)) + { + filtered[attribute.Key] = value; + } + } + + return filtered; + } + + /// + /// Generates a cache key for storing and retrieving CMAB decisions. + /// + /// The user ID. + /// The experiment/rule ID. + /// A cache key string in the format: {userId.Length}-{userId}-{ruleId} + /// + /// The length prefix prevents key collisions between different user IDs that might appear + /// similar when concatenated (e.g., "12-abc-exp" vs "1-2abc-exp"). + /// + internal static string GetCacheKey(string userId, string ruleId) + { + var normalizedUserId = userId ?? string.Empty; + return $"{normalizedUserId.Length}-{normalizedUserId}-{ruleId}"; + } + + /// + /// Computes an MD5 hash of the user attributes for cache validation. + /// + /// The user attributes to hash. + /// A hexadecimal MD5 hash string of the serialized attributes. + /// + /// Attributes are sorted by key before hashing to ensure consistent hashes regardless of + /// the order in which attributes are provided. This allows cache hits when the same attributes + /// are present in different orders. + /// + internal static string HashAttributes(UserAttributes attributes) + { + var ordered = attributes.OrderBy(kvp => kvp.Key).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + var serialized = JsonConvert.SerializeObject(ordered); + + using (var md5 = MD5.Create()) + { + var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(serialized)); + var builder = new StringBuilder(hashBytes.Length * 2); + foreach (var b in hashBytes) + { + builder.Append(b.ToString("x2")); + } + + return builder.ToString(); + } + } + } +} diff --git a/OptimizelySDK/Cmab/ICmabService.cs b/OptimizelySDK/Cmab/ICmabService.cs new file mode 100644 index 00000000..3b909295 --- /dev/null +++ b/OptimizelySDK/Cmab/ICmabService.cs @@ -0,0 +1,31 @@ +/* +* Copyright 2025, Optimizely +* +* 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. +*/ + +using OptimizelySDK.OptimizelyDecisions; + +namespace OptimizelySDK.Cmab +{ + /// + /// Contract for CMAB decision services. + /// + public interface ICmabService + { + CmabDecision GetDecision(ProjectConfig projectConfig, + OptimizelyUserContext userContext, + string ruleId, + OptimizelyDecideOption[] options); + } +} diff --git a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs index b0ec5307..1b7379ff 100644 --- a/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs +++ b/OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs @@ -23,5 +23,8 @@ public enum OptimizelyDecideOption IGNORE_USER_PROFILE_SERVICE, INCLUDE_REASONS, EXCLUDE_VARIABLES, + IGNORE_CMAB_CACHE, + RESET_CMAB_CACHE, + INVALIDATE_USER_CMAB_CACHE, } } diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index ccd53f42..7091cf01 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -205,7 +205,9 @@ + +