Skip to content

Commit 375e6a2

Browse files
[FSSDK-11168] feat: add cmab service (#393)
1 parent 34c736c commit 375e6a2

File tree

7 files changed

+692
-0
lines changed

7 files changed

+692
-0
lines changed

OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@
187187
<Compile Include="..\OptimizelySDK\Cmab\DefaultCmabClient.cs">
188188
<Link>Cmab\DefaultCmabClient.cs</Link>
189189
</Compile>
190+
<Compile Include="..\OptimizelySDK\Cmab\ICmabService.cs">
191+
<Link>Cmab\ICmabService.cs</Link>
192+
</Compile>
193+
<Compile Include="..\OptimizelySDK\Cmab\DefaultCmabService.cs">
194+
<Link>Cmab\DefaultCmabService.cs</Link>
195+
</Compile>
190196
<Compile Include="..\OptimizelySDK\Cmab\CmabRetryConfig.cs">
191197
<Link>Cmab\CmabRetryConfig.cs</Link>
192198
</Compile>

OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs

Lines changed: 388 additions & 0 deletions
Large diffs are not rendered by default.

OptimizelySDK.Tests/OptimizelySDK.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
<ItemGroup>
7272
<Compile Include="Assertions.cs"/>
7373
<Compile Include="CmabTests\DefaultCmabClientTest.cs"/>
74+
<Compile Include="CmabTests\DefaultCmabServiceTest.cs"/>
7475
<Compile Include="AudienceConditionsTests\ConditionEvaluationTest.cs"/>
7576
<Compile Include="AudienceConditionsTests\ConditionsTest.cs"/>
7677
<Compile Include="AudienceConditionsTests\SegmentsTests.cs"/>
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/*
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
using System;
18+
using System.Collections.Generic;
19+
using System.Linq;
20+
using System.Security.Cryptography;
21+
using System.Text;
22+
using Newtonsoft.Json;
23+
using OptimizelySDK;
24+
using OptimizelySDK.Entity;
25+
using OptimizelySDK.Logger;
26+
using OptimizelySDK.Odp;
27+
using OptimizelySDK.OptimizelyDecisions;
28+
using AttributeEntity = OptimizelySDK.Entity.Attribute;
29+
30+
namespace OptimizelySDK.Cmab
31+
{
32+
/// <summary>
33+
/// Represents a CMAB decision response returned by the service.
34+
/// </summary>
35+
public class CmabDecision
36+
{
37+
/// <summary>
38+
/// Initializes a new instance of the CmabDecision class.
39+
/// </summary>
40+
/// <param name="variationId">The variation ID assigned by the CMAB service.</param>
41+
/// <param name="cmabUuid">The unique identifier for this CMAB decision.</param>
42+
public CmabDecision(string variationId, string cmabUuid)
43+
{
44+
VariationId = variationId;
45+
CmabUuid = cmabUuid;
46+
}
47+
48+
/// <summary>
49+
/// Gets the variation ID assigned by the CMAB service.
50+
/// </summary>
51+
public string VariationId { get; }
52+
53+
/// <summary>
54+
/// Gets the unique identifier for this CMAB decision.
55+
/// </summary>
56+
public string CmabUuid { get; }
57+
}
58+
59+
/// <summary>
60+
/// Represents a cached CMAB decision entry.
61+
/// </summary>
62+
public class CmabCacheEntry
63+
{
64+
/// <summary>
65+
/// Gets or sets the hash of the filtered attributes used for this decision.
66+
/// </summary>
67+
public string AttributesHash { get; set; }
68+
69+
/// <summary>
70+
/// Gets or sets the variation ID from the cached decision.
71+
/// </summary>
72+
public string VariationId { get; set; }
73+
74+
/// <summary>
75+
/// Gets or sets the CMAB UUID from the cached decision.
76+
/// </summary>
77+
public string CmabUuid { get; set; }
78+
}
79+
80+
/// <summary>
81+
/// Default implementation of the CMAB decision service that handles caching and filtering.
82+
/// Provides methods for retrieving CMAB decisions with intelligent caching based on user attributes.
83+
/// </summary>
84+
public class DefaultCmabService : ICmabService
85+
{
86+
private readonly LruCache<CmabCacheEntry> _cmabCache;
87+
private readonly ICmabClient _cmabClient;
88+
private readonly ILogger _logger;
89+
90+
/// <summary>
91+
/// Initializes a new instance of the DefaultCmabService class.
92+
/// </summary>
93+
/// <param name="cmabCache">LRU cache for storing CMAB decisions.</param>
94+
/// <param name="cmabClient">Client for fetching decisions from the CMAB prediction service.</param>
95+
/// <param name="logger">Optional logger for recording service operations.</param>
96+
public DefaultCmabService(LruCache<CmabCacheEntry> cmabCache,
97+
ICmabClient cmabClient,
98+
ILogger logger = null)
99+
{
100+
_cmabCache = cmabCache;
101+
_cmabClient = cmabClient;
102+
_logger = logger ?? new NoOpLogger();
103+
}
104+
105+
public CmabDecision GetDecision(ProjectConfig projectConfig,
106+
OptimizelyUserContext userContext,
107+
string ruleId,
108+
OptimizelyDecideOption[] options = null)
109+
{
110+
var optionSet = options ?? new OptimizelyDecideOption[0];
111+
var filteredAttributes = FilterAttributes(projectConfig, userContext, ruleId);
112+
113+
if (optionSet.Contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE))
114+
{
115+
_logger.Log(LogLevel.DEBUG, "Ignoring CMAB cache.");
116+
return FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes);
117+
}
118+
119+
if (optionSet.Contains(OptimizelyDecideOption.RESET_CMAB_CACHE))
120+
{
121+
_logger.Log(LogLevel.DEBUG, "Resetting CMAB cache.");
122+
_cmabCache.Reset();
123+
}
124+
125+
var cacheKey = GetCacheKey(userContext.GetUserId(), ruleId);
126+
127+
if (optionSet.Contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE))
128+
{
129+
_logger.Log(LogLevel.DEBUG, "Invalidating user CMAB cache.");
130+
_cmabCache.Remove(cacheKey);
131+
}
132+
133+
var cachedValue = _cmabCache.Lookup(cacheKey);
134+
var attributesHash = HashAttributes(filteredAttributes);
135+
136+
if (cachedValue != null)
137+
{
138+
if (string.Equals(cachedValue.AttributesHash, attributesHash, StringComparison.Ordinal))
139+
{
140+
return new CmabDecision(cachedValue.VariationId, cachedValue.CmabUuid);
141+
}
142+
else
143+
{
144+
_cmabCache.Remove(cacheKey);
145+
}
146+
147+
}
148+
149+
var cmabDecision = FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes);
150+
151+
_cmabCache.Save(cacheKey, new CmabCacheEntry
152+
{
153+
AttributesHash = attributesHash,
154+
VariationId = cmabDecision.VariationId,
155+
CmabUuid = cmabDecision.CmabUuid,
156+
});
157+
158+
return cmabDecision;
159+
}
160+
161+
/// <summary>
162+
/// Fetches a new decision from the CMAB client and generates a unique UUID for tracking.
163+
/// </summary>
164+
/// <param name="ruleId">The experiment/rule ID.</param>
165+
/// <param name="userId">The user ID.</param>
166+
/// <param name="attributes">The filtered user attributes to send to the CMAB service.</param>
167+
/// <returns>A new CmabDecision with the assigned variation and generated UUID.</returns>
168+
private CmabDecision FetchDecision(string ruleId,
169+
string userId,
170+
UserAttributes attributes)
171+
{
172+
var cmabUuid = Guid.NewGuid().ToString();
173+
var variationId = _cmabClient.FetchDecision(ruleId, userId, attributes, cmabUuid);
174+
return new CmabDecision(variationId, cmabUuid);
175+
}
176+
177+
/// <summary>
178+
/// Filters user attributes to include only those configured for the CMAB experiment.
179+
/// </summary>
180+
/// <param name="projectConfig">The project configuration containing attribute mappings.</param>
181+
/// <param name="userContext">The user context with all user attributes.</param>
182+
/// <param name="ruleId">The experiment/rule ID to get CMAB attribute configuration for.</param>
183+
/// <returns>A UserAttributes object containing only the filtered attributes, or empty if no CMAB config exists.</returns>
184+
/// <remarks>
185+
/// Only attributes specified in the experiment's CMAB configuration are included.
186+
/// This ensures that cache invalidation is based only on relevant attributes.
187+
/// </remarks>
188+
private UserAttributes FilterAttributes(ProjectConfig projectConfig,
189+
OptimizelyUserContext userContext,
190+
string ruleId)
191+
{
192+
var filtered = new UserAttributes();
193+
194+
if (projectConfig.ExperimentIdMap == null ||
195+
!projectConfig.ExperimentIdMap.TryGetValue(ruleId, out var experiment) ||
196+
experiment?.Cmab?.AttributeIds == null ||
197+
experiment.Cmab.AttributeIds.Count == 0)
198+
{
199+
return filtered;
200+
}
201+
202+
var userAttributes = userContext.GetAttributes() ?? new UserAttributes();
203+
var attributeIdMap = projectConfig.AttributeIdMap ?? new Dictionary<string, AttributeEntity>();
204+
205+
foreach (var attributeId in experiment.Cmab.AttributeIds)
206+
{
207+
if (attributeIdMap.TryGetValue(attributeId, out var attribute) &&
208+
userAttributes.TryGetValue(attribute.Key, out var value))
209+
{
210+
filtered[attribute.Key] = value;
211+
}
212+
}
213+
214+
return filtered;
215+
}
216+
217+
/// <summary>
218+
/// Generates a cache key for storing and retrieving CMAB decisions.
219+
/// </summary>
220+
/// <param name="userId">The user ID.</param>
221+
/// <param name="ruleId">The experiment/rule ID.</param>
222+
/// <returns>A cache key string in the format: {userId.Length}-{userId}-{ruleId}</returns>
223+
/// <remarks>
224+
/// The length prefix prevents key collisions between different user IDs that might appear
225+
/// similar when concatenated (e.g., "12-abc-exp" vs "1-2abc-exp").
226+
/// </remarks>
227+
internal static string GetCacheKey(string userId, string ruleId)
228+
{
229+
var normalizedUserId = userId ?? string.Empty;
230+
return $"{normalizedUserId.Length}-{normalizedUserId}-{ruleId}";
231+
}
232+
233+
/// <summary>
234+
/// Computes an MD5 hash of the user attributes for cache validation.
235+
/// </summary>
236+
/// <param name="attributes">The user attributes to hash.</param>
237+
/// <returns>A hexadecimal MD5 hash string of the serialized attributes.</returns>
238+
/// <remarks>
239+
/// Attributes are sorted by key before hashing to ensure consistent hashes regardless of
240+
/// the order in which attributes are provided. This allows cache hits when the same attributes
241+
/// are present in different orders.
242+
/// </remarks>
243+
internal static string HashAttributes(UserAttributes attributes)
244+
{
245+
var ordered = attributes.OrderBy(kvp => kvp.Key).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
246+
var serialized = JsonConvert.SerializeObject(ordered);
247+
248+
using (var md5 = MD5.Create())
249+
{
250+
var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(serialized));
251+
var builder = new StringBuilder(hashBytes.Length * 2);
252+
foreach (var b in hashBytes)
253+
{
254+
builder.Append(b.ToString("x2"));
255+
}
256+
257+
return builder.ToString();
258+
}
259+
}
260+
}
261+
}

OptimizelySDK/Cmab/ICmabService.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
using OptimizelySDK.OptimizelyDecisions;
18+
19+
namespace OptimizelySDK.Cmab
20+
{
21+
/// <summary>
22+
/// Contract for CMAB decision services.
23+
/// </summary>
24+
public interface ICmabService
25+
{
26+
CmabDecision GetDecision(ProjectConfig projectConfig,
27+
OptimizelyUserContext userContext,
28+
string ruleId,
29+
OptimizelyDecideOption[] options);
30+
}
31+
}

OptimizelySDK/OptimizelyDecisions/OptimizelyDecideOption.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,8 @@ public enum OptimizelyDecideOption
2323
IGNORE_USER_PROFILE_SERVICE,
2424
INCLUDE_REASONS,
2525
EXCLUDE_VARIABLES,
26+
IGNORE_CMAB_CACHE,
27+
RESET_CMAB_CACHE,
28+
INVALIDATE_USER_CMAB_CACHE,
2629
}
2730
}

OptimizelySDK/OptimizelySDK.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,9 @@
205205
<Compile Include="Entity\Result.cs"/>
206206
<Compile Include="Notifications\NotificationCenterRegistry.cs"/>
207207
<Compile Include="Cmab\ICmabClient.cs"/>
208+
<Compile Include="Cmab\ICmabService.cs"/>
208209
<Compile Include="Cmab\DefaultCmabClient.cs"/>
210+
<Compile Include="Cmab\DefaultCmabService.cs"/>
209211
<Compile Include="Cmab\CmabRetryConfig.cs"/>
210212
<Compile Include="Cmab\CmabModels.cs"/>
211213
<Compile Include="Cmab\CmabConstants.cs"/>

0 commit comments

Comments
 (0)