-
Notifications
You must be signed in to change notification settings - Fork 20
[FSSDK-11168] feat: add cmab service #393
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
c943f08
[FSSDK-11168] cmab service impl
junaed-optimizely 44cc34a
[FSSDK-11168] format fix
junaed-optimizely cdd0f6b
[FSSDK-11168] test addition
junaed-optimizely 04861cc
[FSSDK-11168] improvements
junaed-optimizely a1fd475
[FSSDK-11168] review update
junaed-optimizely File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
388 changes: 388 additions & 0 deletions
388
OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,261 @@ | ||
/* | ||
* Copyright 2025, Optimizely | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using System.Security.Cryptography; | ||
using System.Text; | ||
using Newtonsoft.Json; | ||
using OptimizelySDK; | ||
using OptimizelySDK.Entity; | ||
using OptimizelySDK.Logger; | ||
using OptimizelySDK.Odp; | ||
using OptimizelySDK.OptimizelyDecisions; | ||
using AttributeEntity = OptimizelySDK.Entity.Attribute; | ||
|
||
namespace OptimizelySDK.Cmab | ||
{ | ||
/// <summary> | ||
/// Represents a CMAB decision response returned by the service. | ||
/// </summary> | ||
public class CmabDecision | ||
{ | ||
/// <summary> | ||
/// Initializes a new instance of the CmabDecision class. | ||
/// </summary> | ||
/// <param name="variationId">The variation ID assigned by the CMAB service.</param> | ||
/// <param name="cmabUuid">The unique identifier for this CMAB decision.</param> | ||
public CmabDecision(string variationId, string cmabUuid) | ||
{ | ||
VariationId = variationId; | ||
CmabUuid = cmabUuid; | ||
} | ||
|
||
/// <summary> | ||
/// Gets the variation ID assigned by the CMAB service. | ||
/// </summary> | ||
public string VariationId { get; } | ||
|
||
/// <summary> | ||
/// Gets the unique identifier for this CMAB decision. | ||
/// </summary> | ||
public string CmabUuid { get; } | ||
} | ||
|
||
/// <summary> | ||
/// Represents a cached CMAB decision entry. | ||
/// </summary> | ||
public class CmabCacheEntry | ||
{ | ||
/// <summary> | ||
/// Gets or sets the hash of the filtered attributes used for this decision. | ||
/// </summary> | ||
public string AttributesHash { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets the variation ID from the cached decision. | ||
/// </summary> | ||
public string VariationId { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets the CMAB UUID from the cached decision. | ||
/// </summary> | ||
public string CmabUuid { get; set; } | ||
} | ||
|
||
/// <summary> | ||
/// 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. | ||
/// </summary> | ||
public class DefaultCmabService : ICmabService | ||
{ | ||
private readonly LruCache<CmabCacheEntry> _cmabCache; | ||
private readonly ICmabClient _cmabClient; | ||
private readonly ILogger _logger; | ||
|
||
/// <summary> | ||
/// Initializes a new instance of the DefaultCmabService class. | ||
/// </summary> | ||
/// <param name="cmabCache">LRU cache for storing CMAB decisions.</param> | ||
/// <param name="cmabClient">Client for fetching decisions from the CMAB prediction service.</param> | ||
/// <param name="logger">Optional logger for recording service operations.</param> | ||
public DefaultCmabService(LruCache<CmabCacheEntry> cmabCache, | ||
ICmabClient cmabClient, | ||
ILogger logger = null) | ||
{ | ||
_cmabCache = cmabCache; | ||
_cmabClient = cmabClient; | ||
_logger = logger ?? new NoOpLogger(); | ||
} | ||
|
||
public CmabDecision GetDecision(ProjectConfig projectConfig, | ||
OptimizelyUserContext userContext, | ||
string ruleId, | ||
OptimizelyDecideOption[] options = null) | ||
{ | ||
var optionSet = options ?? new OptimizelyDecideOption[0]; | ||
var filteredAttributes = FilterAttributes(projectConfig, userContext, ruleId); | ||
|
||
if (optionSet.Contains(OptimizelyDecideOption.IGNORE_CMAB_CACHE)) | ||
{ | ||
_logger.Log(LogLevel.DEBUG, "Ignoring CMAB cache."); | ||
return FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes); | ||
junaed-optimizely marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
if (optionSet.Contains(OptimizelyDecideOption.RESET_CMAB_CACHE)) | ||
{ | ||
_logger.Log(LogLevel.DEBUG, "Resetting CMAB cache."); | ||
_cmabCache.Reset(); | ||
junaed-optimizely marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
var cacheKey = GetCacheKey(userContext.GetUserId(), ruleId); | ||
|
||
if (optionSet.Contains(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE)) | ||
{ | ||
junaed-optimizely marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_logger.Log(LogLevel.DEBUG, "Invalidating user CMAB cache."); | ||
_cmabCache.Remove(cacheKey); | ||
} | ||
|
||
var cachedValue = _cmabCache.Lookup(cacheKey); | ||
var attributesHash = HashAttributes(filteredAttributes); | ||
|
||
if (cachedValue != null) | ||
{ | ||
if (string.Equals(cachedValue.AttributesHash, attributesHash, StringComparison.Ordinal)) | ||
{ | ||
return new CmabDecision(cachedValue.VariationId, cachedValue.CmabUuid); | ||
} | ||
else | ||
{ | ||
_cmabCache.Remove(cacheKey); | ||
} | ||
|
||
junaed-optimizely marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
var cmabDecision = FetchDecision(ruleId, userContext.GetUserId(), filteredAttributes); | ||
|
||
_cmabCache.Save(cacheKey, new CmabCacheEntry | ||
{ | ||
AttributesHash = attributesHash, | ||
VariationId = cmabDecision.VariationId, | ||
CmabUuid = cmabDecision.CmabUuid, | ||
}); | ||
|
||
return cmabDecision; | ||
} | ||
|
||
/// <summary> | ||
/// Fetches a new decision from the CMAB client and generates a unique UUID for tracking. | ||
/// </summary> | ||
/// <param name="ruleId">The experiment/rule ID.</param> | ||
/// <param name="userId">The user ID.</param> | ||
/// <param name="attributes">The filtered user attributes to send to the CMAB service.</param> | ||
/// <returns>A new CmabDecision with the assigned variation and generated UUID.</returns> | ||
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); | ||
} | ||
|
||
/// <summary> | ||
/// Filters user attributes to include only those configured for the CMAB experiment. | ||
/// </summary> | ||
/// <param name="projectConfig">The project configuration containing attribute mappings.</param> | ||
/// <param name="userContext">The user context with all user attributes.</param> | ||
/// <param name="ruleId">The experiment/rule ID to get CMAB attribute configuration for.</param> | ||
/// <returns>A UserAttributes object containing only the filtered attributes, or empty if no CMAB config exists.</returns> | ||
/// <remarks> | ||
/// Only attributes specified in the experiment's CMAB configuration are included. | ||
/// This ensures that cache invalidation is based only on relevant attributes. | ||
/// </remarks> | ||
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<string, AttributeEntity>(); | ||
|
||
foreach (var attributeId in experiment.Cmab.AttributeIds) | ||
{ | ||
if (attributeIdMap.TryGetValue(attributeId, out var attribute) && | ||
userAttributes.TryGetValue(attribute.Key, out var value)) | ||
{ | ||
filtered[attribute.Key] = value; | ||
} | ||
} | ||
|
||
return filtered; | ||
} | ||
|
||
/// <summary> | ||
/// Generates a cache key for storing and retrieving CMAB decisions. | ||
/// </summary> | ||
/// <param name="userId">The user ID.</param> | ||
/// <param name="ruleId">The experiment/rule ID.</param> | ||
/// <returns>A cache key string in the format: {userId.Length}-{userId}-{ruleId}</returns> | ||
/// <remarks> | ||
/// 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"). | ||
/// </remarks> | ||
internal static string GetCacheKey(string userId, string ruleId) | ||
{ | ||
var normalizedUserId = userId ?? string.Empty; | ||
return $"{normalizedUserId.Length}-{normalizedUserId}-{ruleId}"; | ||
} | ||
|
||
/// <summary> | ||
/// Computes an MD5 hash of the user attributes for cache validation. | ||
/// </summary> | ||
/// <param name="attributes">The user attributes to hash.</param> | ||
/// <returns>A hexadecimal MD5 hash string of the serialized attributes.</returns> | ||
/// <remarks> | ||
/// 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. | ||
/// </remarks> | ||
internal static string HashAttributes(UserAttributes attributes) | ||
{ | ||
var ordered = attributes.OrderBy(kvp => kvp.Key).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); | ||
var serialized = JsonConvert.SerializeObject(ordered); | ||
|
||
using (var md5 = MD5.Create()) | ||
junaed-optimizely marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
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(); | ||
} | ||
} | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
{ | ||
/// <summary> | ||
/// Contract for CMAB decision services. | ||
/// </summary> | ||
public interface ICmabService | ||
{ | ||
CmabDecision GetDecision(ProjectConfig projectConfig, | ||
OptimizelyUserContext userContext, | ||
string ruleId, | ||
OptimizelyDecideOption[] options); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.