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);