From c943f08bc307bf5a14d294a1576979e169c21fe3 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Tue, 7 Oct 2025 22:57:38 +0600 Subject: [PATCH 1/5] [FSSDK-11168] cmab service impl --- OptimizelySDK/Cmab/DefaultCmabService.cs | 187 ++++++++++++++++++ OptimizelySDK/Cmab/ICmabService.cs | 31 +++ .../OptimizelyDecideOption.cs | 3 + OptimizelySDK/OptimizelySDK.csproj | 2 + 4 files changed, 223 insertions(+) create mode 100644 OptimizelySDK/Cmab/DefaultCmabService.cs create mode 100644 OptimizelySDK/Cmab/ICmabService.cs diff --git a/OptimizelySDK/Cmab/DefaultCmabService.cs b/OptimizelySDK/Cmab/DefaultCmabService.cs new file mode 100644 index 00000000..f9730cc6 --- /dev/null +++ b/OptimizelySDK/Cmab/DefaultCmabService.cs @@ -0,0 +1,187 @@ +/* +* 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 + { + public CmabDecision(string variationId, string cmabUuid) + { + VariationId = variationId; + CmabUuid = cmabUuid; + } + + public string VariationId { get; } + public string CmabUuid { get; } + } + + public class CmabCacheEntry + { + public string AttributesHash { get; set; } + public string VariationId { get; set; } + public string CmabUuid { get; set; } + } + + /// + /// Default implementation of the CMAB decision service that handles caching and filtering. + /// + public class DefaultCmabService : ICmabService + { + private readonly LruCache _cmabCache; + private readonly ICmabClient _cmabClient; + private readonly ILogger _logger; + + 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) + { + var optionSet = options ?? new OptimizelyDecideOption[0]; + var filteredAttributes = FilterAttributes(projectConfig, userContext, ruleId); + + if (optionSet.Contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE)) + { + return FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes); + } + + if (optionSet.Contains(OptimizelyDecideOption.RESET_CMAB_CACHE)) + { + _cmabCache.Reset(); + } + + var cacheKey = GetCacheKey(userContext.GetUserId(), ruleId); + + if (optionSet.Contains(OptimizelyDecideOption.INVALIDATE_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); + } + + _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; + } + + 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); + } + + 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 AttributeEntity attribute) && + attribute != null && + !string.IsNullOrEmpty(attribute.Key) && + userAttributes.TryGetValue(attribute.Key, out var value)) + { + filtered[attribute.Key] = value; + } + } + + return filtered; + } + + private string GetCacheKey(string userId, string ruleId) + { + var normalizedUserId = userId ?? string.Empty; + return $"{normalizedUserId.Length}-{normalizedUserId}-{ruleId}"; + } + + private 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..1f956db3 --- /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..40fdd8da 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -205,7 +205,9 @@ + + From 44cc34ab8ef2f2117a71ca70e8b72b8c3d52a380 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Tue, 7 Oct 2025 23:06:34 +0600 Subject: [PATCH 2/5] [FSSDK-11168] format fix --- OptimizelySDK/Cmab/DefaultCmabService.cs | 5 ++--- OptimizelySDK/Cmab/ICmabService.cs | 2 +- OptimizelySDK/OptimizelySDK.csproj | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/OptimizelySDK/Cmab/DefaultCmabService.cs b/OptimizelySDK/Cmab/DefaultCmabService.cs index f9730cc6..13956644 100644 --- a/OptimizelySDK/Cmab/DefaultCmabService.cs +++ b/OptimizelySDK/Cmab/DefaultCmabService.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright 2025, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -167,8 +167,7 @@ private string GetCacheKey(string userId, string ruleId) private string HashAttributes(UserAttributes attributes) { - var ordered = attributes.OrderBy(kvp => kvp.Key) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + var ordered = attributes.OrderBy(kvp => kvp.Key).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); var serialized = JsonConvert.SerializeObject(ordered); using (var md5 = MD5.Create()) diff --git a/OptimizelySDK/Cmab/ICmabService.cs b/OptimizelySDK/Cmab/ICmabService.cs index 1f956db3..3b909295 100644 --- a/OptimizelySDK/Cmab/ICmabService.cs +++ b/OptimizelySDK/Cmab/ICmabService.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright 2025, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index 40fdd8da..7091cf01 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -205,7 +205,7 @@ - + From cdd0f6b8ded7dc8982f3a10383d7812d22851d7b Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 8 Oct 2025 00:02:56 +0600 Subject: [PATCH 3/5] [FSSDK-11168] test addition --- .../CmabTests/DefaultCmabServiceTest.cs | 384 ++++++++++++++++++ .../OptimizelySDK.Tests.csproj | 1 + OptimizelySDK/Cmab/DefaultCmabService.cs | 4 +- 3 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs new file mode 100644 index 00000000..676faea5 --- /dev/null +++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs @@ -0,0 +1,384 @@ +/* +* 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.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; + + [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); + } + + [Test] + public void ReturnsDecisionFromCacheWhenHashMatches() + { + var ruleId = "exp1"; + var userId = "user123"; + var experiment = CreateExperiment(ruleId, new List { "66" }); + var attributeMap = new Dictionary + { + { "66", new AttributeEntity { Id = "66", Key = "age" } } + }; + var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); + var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + var filteredAttributes = new UserAttributes(new Dictionary { { "age", 25 } }); + var cacheKey = DefaultCmabService.GetCacheKey(userId, ruleId); + + _cmabCache.Save(cacheKey, new CmabCacheEntry + { + AttributesHash = DefaultCmabService.HashAttributes(filteredAttributes), + CmabUuid = "uuid-cached", + VariationId = "varA" + }); + + var decision = _cmabService.GetDecision(projectConfig, userContext, ruleId, null); + + 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 ruleId = "exp1"; + var userId = "user123"; + var experiment = CreateExperiment(ruleId, new List { "66" }); + var attributeMap = new Dictionary + { + { "66", new AttributeEntity { Id = "66", Key = "age" } } + }; + var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); + var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + var cacheKey = DefaultCmabService.GetCacheKey(userId, ruleId); + + _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + 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, ruleId, + new[] { OptimizelyDecideOption.IGNORE_CMAB_CACHE }); + + Assert.AreEqual("varB", decision.VariationId); + Assert.IsNull(_cmabCache.Lookup(cacheKey)); + _mockCmabClient.VerifyAll(); + } + + [Test] + public void ResetsCacheWhenOptionSpecified() + { + var ruleId = "exp1"; + var userId = "user123"; + var experiment = CreateExperiment(ruleId, new List { "66" }); + var attributeMap = new Dictionary + { + { "66", new AttributeEntity { Id = "66", Key = "age" } } + }; + var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); + var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + var cacheKey = DefaultCmabService.GetCacheKey(userId, ruleId); + + _cmabCache.Save(cacheKey, new CmabCacheEntry + { + AttributesHash = "stale", + CmabUuid = "uuid-old", + VariationId = "varOld" + }); + + _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25), + It.IsAny(), + It.IsAny())).Returns("varNew"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, ruleId, + new[] { OptimizelyDecideOption.RESET_CMAB_CACHE }); + + 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 ruleId = "exp1"; + var userId = "user123"; + var otherUserId = "other"; + var experiment = CreateExperiment(ruleId, new List { "66" }); + var attributeMap = new Dictionary + { + { "66", new AttributeEntity { Id = "66", Key = "age" } } + }; + var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); + var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + + var targetKey = DefaultCmabService.GetCacheKey(userId, ruleId); + var otherKey = DefaultCmabService.GetCacheKey(otherUserId, ruleId); + + _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(ruleId, userId, + It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25), + It.IsAny(), + It.IsAny())).Returns("varNew"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, ruleId, + new[] { OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE }); + + 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 ruleId = "exp1"; + var userId = "user123"; + var experiment = CreateExperiment(ruleId, new List { "66" }); + var attributeMap = new Dictionary + { + { "66", new AttributeEntity { Id = "66", Key = "age" } } + }; + var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); + var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + + var cacheKey = DefaultCmabService.GetCacheKey(userId, ruleId); + _cmabCache.Save(cacheKey, new CmabCacheEntry + { + AttributesHash = "different_hash", + CmabUuid = "uuid-old", + VariationId = "varOld" + }); + + _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + It.Is>(attrs => attrs.Count == 1 && (int)attrs["age"] == 25), + It.IsAny(), + It.IsAny())).Returns("varUpdated"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, ruleId, null); + + 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 ruleId = "exp1"; + var userId = "user123"; + var experiment = CreateExperiment(ruleId, new List { "66", "77" }); + var attributeMap = new Dictionary + { + { "66", new AttributeEntity { Id = "66", Key = "age" } }, + { "77", new AttributeEntity { Id = "77", Key = "location" } } + }; + var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); + var userContext = CreateUserContext(userId, new Dictionary + { + { "age", 25 }, + { "location", "USA" }, + { "extra", "value" } + }); + + _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + 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, ruleId, null); + + Assert.AreEqual("varFiltered", decision.VariationId); + _mockCmabClient.VerifyAll(); + } + + [Test] + public void HandlesMissingCmabConfiguration() + { + var ruleId = "exp1"; + var userId = "user123"; + var experiment = CreateExperiment(ruleId, null); + var attributeMap = new Dictionary(); + var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); + var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + + _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + It.Is>(attrs => attrs.Count == 0), + It.IsAny(), + It.IsAny())).Returns("varDefault"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, ruleId, null); + + Assert.AreEqual("varDefault", decision.VariationId); + _mockCmabClient.VerifyAll(); + } + + [Test] + public void AttributeHashIsStableRegardlessOfOrder() + { + var ruleId = "exp1"; + var userId = "user123"; + var experiment = CreateExperiment(ruleId, new List { "66", "77" }); + var attributeMap = new Dictionary + { + { "66", new AttributeEntity { Id = "66", Key = "a" } }, + { "77", new AttributeEntity { Id = "77", Key = "b" } } + }; + var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); + + var firstContext = CreateUserContext(userId, new Dictionary + { + { "b", 2 }, + { "a", 1 } + }); + + _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns("varStable"); + + var firstDecision = _cmabService.GetDecision(projectConfig, firstContext, ruleId, null); + Assert.AreEqual("varStable", firstDecision.VariationId); + + var secondContext = CreateUserContext(userId, new Dictionary + { + { "a", 1 }, + { "b", 2 } + }); + + var secondDecision = _cmabService.GetDecision(projectConfig, secondContext, ruleId, null); + + Assert.AreEqual("varStable", secondDecision.VariationId); + _mockCmabClient.Verify(c => c.FetchDecision(ruleId, userId, + It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void UsesExpectedCacheKeyFormat() + { + var ruleId = "exp1"; + var userId = "user123"; + var experiment = CreateExperiment(ruleId, new List { "66" }); + var attributeMap = new Dictionary + { + { "66", new AttributeEntity { Id = "66", Key = "age" } } + }; + var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); + var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + + _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns("varKey"); + + var decision = _cmabService.GetDecision(projectConfig, userContext, ruleId, null); + Assert.AreEqual("varKey", decision.VariationId); + + var cacheKey = DefaultCmabService.GetCacheKey(userId, ruleId); + var cachedEntry = _cmabCache.Lookup(cacheKey); + Assert.IsNotNull(cachedEntry); + Assert.AreEqual(decision.CmabUuid, cachedEntry.CmabUuid); + } + + private static OptimizelyUserContext CreateUserContext(string userId, IDictionary attributes) + { + var userAttributes = new UserAttributes(attributes); + return new OptimizelyUserContext(null, userId, userAttributes, null, null, + new NoOpErrorHandler(), new NoOpLogger() +#if !(NET35 || NET40 || NETSTANDARD1_6) + , false +#endif + ); + } + + 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 index 13956644..67b95493 100644 --- a/OptimizelySDK/Cmab/DefaultCmabService.cs +++ b/OptimizelySDK/Cmab/DefaultCmabService.cs @@ -159,13 +159,13 @@ private UserAttributes FilterAttributes(ProjectConfig projectConfig, return filtered; } - private string GetCacheKey(string userId, string ruleId) + internal static string GetCacheKey(string userId, string ruleId) { var normalizedUserId = userId ?? string.Empty; return $"{normalizedUserId.Length}-{normalizedUserId}-{ruleId}"; } - private string HashAttributes(UserAttributes attributes) + 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); From 04861cc3503265f32917a8458358999e760ebba7 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:34:44 +0600 Subject: [PATCH 4/5] [FSSDK-11168] improvements --- .../CmabTests/DefaultCmabServiceTest.cs | 184 +++++++++--------- OptimizelySDK/Cmab/DefaultCmabService.cs | 77 +++++++- 2 files changed, 167 insertions(+), 94 deletions(-) diff --git a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs index 676faea5..9dac9699 100644 --- a/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs +++ b/OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs @@ -19,6 +19,7 @@ using Moq; using NUnit.Framework; using OptimizelySDK.Cmab; +using OptimizelySDK.Config; using OptimizelySDK.Entity; using OptimizelySDK.ErrorHandler; using OptimizelySDK.Logger; @@ -35,6 +36,13 @@ public class DefaultCmabServiceTest 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() @@ -43,22 +51,23 @@ public void SetUp() _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 ruleId = "exp1"; - var userId = "user123"; - var experiment = CreateExperiment(ruleId, new List { "66" }); + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); var attributeMap = new Dictionary { - { "66", new AttributeEntity { Id = "66", Key = "age" } } + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } }; - var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); - var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + 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(userId, ruleId); + var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID); _cmabCache.Save(cacheKey, new CmabCacheEntry { @@ -67,8 +76,9 @@ public void ReturnsDecisionFromCacheWhenHashMatches() VariationId = "varA" }); - var decision = _cmabService.GetDecision(projectConfig, userContext, ruleId, null); + 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); @@ -77,25 +87,24 @@ public void ReturnsDecisionFromCacheWhenHashMatches() [Test] public void IgnoresCacheWhenOptionSpecified() { - var ruleId = "exp1"; - var userId = "user123"; - var experiment = CreateExperiment(ruleId, new List { "66" }); + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); var attributeMap = new Dictionary { - { "66", new AttributeEntity { Id = "66", Key = "age" } } + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } }; - var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); - var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); - var cacheKey = DefaultCmabService.GetCacheKey(userId, ruleId); + 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(ruleId, userId, + _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, ruleId, + 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(); @@ -104,16 +113,14 @@ public void IgnoresCacheWhenOptionSpecified() [Test] public void ResetsCacheWhenOptionSpecified() { - var ruleId = "exp1"; - var userId = "user123"; - var experiment = CreateExperiment(ruleId, new List { "66" }); + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); var attributeMap = new Dictionary { - { "66", new AttributeEntity { Id = "66", Key = "age" } } + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } }; - var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); - var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); - var cacheKey = DefaultCmabService.GetCacheKey(userId, ruleId); + 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 { @@ -122,14 +129,15 @@ public void ResetsCacheWhenOptionSpecified() VariationId = "varOld" }); - _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + _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, ruleId, + 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); @@ -141,19 +149,17 @@ public void ResetsCacheWhenOptionSpecified() [Test] public void InvalidatesUserEntryWhenOptionSpecified() { - var ruleId = "exp1"; - var userId = "user123"; var otherUserId = "other"; - var experiment = CreateExperiment(ruleId, new List { "66" }); + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); var attributeMap = new Dictionary { - { "66", new AttributeEntity { Id = "66", Key = "age" } } + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } }; - var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); - var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); - var targetKey = DefaultCmabService.GetCacheKey(userId, ruleId); - var otherKey = DefaultCmabService.GetCacheKey(otherUserId, ruleId); + var targetKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID); + var otherKey = DefaultCmabService.GetCacheKey(otherUserId, TEST_RULE_ID); _cmabCache.Save(targetKey, new CmabCacheEntry { @@ -168,14 +174,15 @@ public void InvalidatesUserEntryWhenOptionSpecified() VariationId = "varOther" }); - _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + _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, ruleId, + 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); @@ -191,17 +198,15 @@ public void InvalidatesUserEntryWhenOptionSpecified() [Test] public void FetchesNewDecisionWhenHashDiffers() { - var ruleId = "exp1"; - var userId = "user123"; - var experiment = CreateExperiment(ruleId, new List { "66" }); + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); var attributeMap = new Dictionary { - { "66", new AttributeEntity { Id = "66", Key = "age" } } + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } }; - var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); - var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); - var cacheKey = DefaultCmabService.GetCacheKey(userId, ruleId); + var cacheKey = DefaultCmabService.GetCacheKey(TEST_USER_ID, TEST_RULE_ID); _cmabCache.Save(cacheKey, new CmabCacheEntry { AttributesHash = "different_hash", @@ -209,13 +214,14 @@ public void FetchesNewDecisionWhenHashDiffers() VariationId = "varOld" }); - _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + _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, ruleId, null); + 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); @@ -227,23 +233,21 @@ public void FetchesNewDecisionWhenHashDiffers() [Test] public void FiltersAttributesBeforeCallingClient() { - var ruleId = "exp1"; - var userId = "user123"; - var experiment = CreateExperiment(ruleId, new List { "66", "77" }); + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID }); var attributeMap = new Dictionary { - { "66", new AttributeEntity { Id = "66", Key = "age" } }, - { "77", new AttributeEntity { Id = "77", Key = "location" } } + { 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(ruleId, experiment, attributeMap); - var userContext = CreateUserContext(userId, new Dictionary + 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(ruleId, userId, + _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" && @@ -251,8 +255,9 @@ public void FiltersAttributesBeforeCallingClient() It.IsAny(), It.IsAny())).Returns("varFiltered"); - var decision = _cmabService.GetDecision(projectConfig, userContext, ruleId, null); + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + Assert.IsNotNull(decision); Assert.AreEqual("varFiltered", decision.VariationId); _mockCmabClient.VerifyAll(); } @@ -260,20 +265,19 @@ public void FiltersAttributesBeforeCallingClient() [Test] public void HandlesMissingCmabConfiguration() { - var ruleId = "exp1"; - var userId = "user123"; - var experiment = CreateExperiment(ruleId, null); + var experiment = CreateExperiment(TEST_RULE_ID, null); var attributeMap = new Dictionary(); - var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); - var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); - _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + _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, ruleId, null); + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + Assert.IsNotNull(decision); Assert.AreEqual("varDefault", decision.VariationId); _mockCmabClient.VerifyAll(); } @@ -281,79 +285,79 @@ public void HandlesMissingCmabConfiguration() [Test] public void AttributeHashIsStableRegardlessOfOrder() { - var ruleId = "exp1"; - var userId = "user123"; - var experiment = CreateExperiment(ruleId, new List { "66", "77" }); + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID, LOCATION_ATTRIBUTE_ID }); var attributeMap = new Dictionary { - { "66", new AttributeEntity { Id = "66", Key = "a" } }, - { "77", new AttributeEntity { Id = "77", Key = "b" } } + { 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(ruleId, experiment, attributeMap); + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); - var firstContext = CreateUserContext(userId, new Dictionary + var firstContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "b", 2 }, { "a", 1 } }); - _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + _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, ruleId, null); + var firstDecision = _cmabService.GetDecision(projectConfig, firstContext, TEST_RULE_ID, null); + Assert.IsNotNull(firstDecision); Assert.AreEqual("varStable", firstDecision.VariationId); - var secondContext = CreateUserContext(userId, new Dictionary + var secondContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "a", 1 }, { "b", 2 } }); - var secondDecision = _cmabService.GetDecision(projectConfig, secondContext, ruleId, null); + var secondDecision = _cmabService.GetDecision(projectConfig, secondContext, TEST_RULE_ID, null); + Assert.IsNotNull(secondDecision); Assert.AreEqual("varStable", secondDecision.VariationId); - _mockCmabClient.Verify(c => c.FetchDecision(ruleId, userId, + _mockCmabClient.Verify(c => c.FetchDecision(TEST_RULE_ID, TEST_USER_ID, It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); } [Test] public void UsesExpectedCacheKeyFormat() { - var ruleId = "exp1"; - var userId = "user123"; - var experiment = CreateExperiment(ruleId, new List { "66" }); + var experiment = CreateExperiment(TEST_RULE_ID, new List { AGE_ATTRIBUTE_ID }); var attributeMap = new Dictionary { - { "66", new AttributeEntity { Id = "66", Key = "age" } } + { AGE_ATTRIBUTE_ID, new AttributeEntity { Id = AGE_ATTRIBUTE_ID, Key = "age" } } }; - var projectConfig = CreateProjectConfig(ruleId, experiment, attributeMap); - var userContext = CreateUserContext(userId, new Dictionary { { "age", 25 } }); + var projectConfig = CreateProjectConfig(TEST_RULE_ID, experiment, attributeMap); + var userContext = CreateUserContext(TEST_USER_ID, new Dictionary { { "age", 25 } }); - _mockCmabClient.Setup(c => c.FetchDecision(ruleId, userId, + _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, ruleId, null); + var decision = _cmabService.GetDecision(projectConfig, userContext, TEST_RULE_ID, null); + Assert.IsNotNull(decision); Assert.AreEqual("varKey", decision.VariationId); - var cacheKey = DefaultCmabService.GetCacheKey(userId, ruleId); + 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 static OptimizelyUserContext CreateUserContext(string userId, IDictionary attributes) + private OptimizelyUserContext CreateUserContext(string userId, IDictionary attributes) { - var userAttributes = new UserAttributes(attributes); - return new OptimizelyUserContext(null, userId, userAttributes, null, null, - new NoOpErrorHandler(), new NoOpLogger() -#if !(NET35 || NET40 || NETSTANDARD1_6) - , false -#endif - ); + 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, diff --git a/OptimizelySDK/Cmab/DefaultCmabService.cs b/OptimizelySDK/Cmab/DefaultCmabService.cs index 67b95493..7592a096 100644 --- a/OptimizelySDK/Cmab/DefaultCmabService.cs +++ b/OptimizelySDK/Cmab/DefaultCmabService.cs @@ -34,25 +34,52 @@ namespace OptimizelySDK.Cmab /// 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 { @@ -60,6 +87,12 @@ public class DefaultCmabService : ICmabService 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) @@ -72,7 +105,7 @@ public DefaultCmabService(LruCache cmabCache, public CmabDecision GetDecision(ProjectConfig projectConfig, OptimizelyUserContext userContext, string ruleId, - OptimizelyDecideOption[] options) + OptimizelyDecideOption[] options = null) { var optionSet = options ?? new OptimizelyDecideOption[0]; var filteredAttributes = FilterAttributes(projectConfig, userContext, ruleId); @@ -119,6 +152,13 @@ public CmabDecision GetDecision(ProjectConfig projectConfig, 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) @@ -128,6 +168,17 @@ private CmabDecision FetchDecision(string ruleId, 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) @@ -147,9 +198,7 @@ private UserAttributes FilterAttributes(ProjectConfig projectConfig, foreach (var attributeId in experiment.Cmab.AttributeIds) { - if (attributeIdMap.TryGetValue(attributeId, out AttributeEntity attribute) && - attribute != null && - !string.IsNullOrEmpty(attribute.Key) && + if (attributeIdMap.TryGetValue(attributeId, out var attribute) && userAttributes.TryGetValue(attribute.Key, out var value)) { filtered[attribute.Key] = value; @@ -159,12 +208,32 @@ private UserAttributes FilterAttributes(ProjectConfig projectConfig, 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); From a1fd475755d175abab18ba469db6ffa34c3e616e Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Thu, 9 Oct 2025 23:15:05 +0600 Subject: [PATCH 5/5] [FSSDK-11168] review update --- .../OptimizelySDK.NetStandard20.csproj | 6 ++++++ OptimizelySDK/Cmab/DefaultCmabService.cs | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) 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/Cmab/DefaultCmabService.cs b/OptimizelySDK/Cmab/DefaultCmabService.cs index 7592a096..2cdf18c3 100644 --- a/OptimizelySDK/Cmab/DefaultCmabService.cs +++ b/OptimizelySDK/Cmab/DefaultCmabService.cs @@ -112,11 +112,13 @@ public CmabDecision GetDecision(ProjectConfig projectConfig, 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(); } @@ -124,6 +126,7 @@ public CmabDecision GetDecision(ProjectConfig projectConfig, if (optionSet.Contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE)) { + _logger.Log(LogLevel.DEBUG, "Invalidating user CMAB cache."); _cmabCache.Remove(cacheKey); } @@ -136,8 +139,11 @@ public CmabDecision GetDecision(ProjectConfig projectConfig, { return new CmabDecision(cachedValue.VariationId, cachedValue.CmabUuid); } + else + { + _cmabCache.Remove(cacheKey); + } - _cmabCache.Remove(cacheKey); } var cmabDecision = FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes);