Skip to content

Commit 40843ac

Browse files
[FSSDK-11177] public api exposed for cmab cache
1 parent ae7b3dd commit 40843ac

File tree

12 files changed

+539
-128
lines changed

12 files changed

+539
-128
lines changed

OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@
199199
<Compile Include="..\OptimizelySDK\Cmab\CmabRetryConfig.cs">
200200
<Link>Cmab\CmabRetryConfig.cs</Link>
201201
</Compile>
202+
<Compile Include="..\OptimizelySDK\Cmab\CmabConfig.cs">
203+
<Link>Cmab\CmabConfig.cs</Link>
204+
</Compile>
202205
<Compile Include="..\OptimizelySDK\Cmab\CmabModels.cs">
203206
<Link>Cmab\CmabModels.cs</Link>
204207
</Compile>

OptimizelySDK.Tests/CmabTests/DefaultCmabServiceTest.cs

Lines changed: 218 additions & 71 deletions
Large diffs are not rendered by default.

OptimizelySDK.Tests/CmabTests/OptimizelyUserContextCmabTest.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ public void TestDecideWithCmabExperimentIncludeReasons()
390390

391391
Assert.IsNotNull(decision);
392392
Assert.IsNotNull(decision.Reasons);
393-
var expectedMessage = string.Format(CmabConstants.CmabDecisionFetched, TEST_USER_ID,
393+
var expectedMessage = string.Format(CmabConstants.CMAB_DECISION_FETCHED, TEST_USER_ID,
394394
TEST_EXPERIMENT_KEY);
395395
Assert.IsTrue(decision.Reasons.Any(r => r.Contains(expectedMessage)),
396396
"Decision reasons should include CMAB fetch success message.");
@@ -415,7 +415,7 @@ public void TestDecideWithCmabErrorReturnsErrorDecision()
415415
Assert.IsNull(decision.VariationKey);
416416
Assert.IsNull(decision.CmabUuid);
417417
Assert.IsTrue(decision.Reasons.Any(r => r.Contains(
418-
string.Format(CmabConstants.CmabFetchFailed, TEST_EXPERIMENT_KEY))));
418+
string.Format(CmabConstants.CMAB_FETCH_FAILED, TEST_EXPERIMENT_KEY))));
419419
Assert.AreEqual(1, _cmabService.CallCount);
420420
}
421421

OptimizelySDK.Tests/OptimizelyFactoryTest.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@
1616
*/
1717

1818
using System;
19+
using System.Reflection;
1920
using Moq;
2021
using NUnit.Framework;
22+
using OptimizelySDK.Bucketing;
23+
using OptimizelySDK.Cmab;
2124
using OptimizelySDK.Config;
2225
using OptimizelySDK.Event;
2326
using OptimizelySDK.Event.Dispatcher;
27+
using OptimizelySDK.ErrorHandler;
2428
using OptimizelySDK.Logger;
2529
using OptimizelySDK.Notifications;
30+
using OptimizelySDK.Odp;
2631
using OptimizelySDK.Tests.ConfigTest;
2732
using OptimizelySDK.Tests.EventTest;
2833
using OptimizelySDK.Tests.Utils;
@@ -39,6 +44,13 @@ public void Initialize()
3944
{
4045
LoggerMock = new Mock<ILogger>();
4146
LoggerMock.Setup(i => i.Log(It.IsAny<LogLevel>(), It.IsAny<string>()));
47+
ResetCmabConfiguration();
48+
}
49+
50+
[TearDown]
51+
public void Cleanup()
52+
{
53+
ResetCmabConfiguration();
4254
}
4355

4456
[Test]
@@ -244,5 +256,76 @@ public void TestGetFeatureVariableJSONEmptyDatafileTest()
244256
"userId"));
245257
optimizely.Dispose();
246258
}
259+
260+
[Test]
261+
public void SetCmabCacheConfigStoresCacheSizeAndTtl()
262+
{
263+
const int cacheSize = 1234;
264+
var cacheTtl = TimeSpan.FromSeconds(45);
265+
266+
OptimizelyFactory.SetCmabCacheConfig(cacheSize, cacheTtl);
267+
268+
var config = GetCurrentCmabConfiguration();
269+
270+
Assert.IsNotNull(config);
271+
Assert.AreEqual(cacheSize, config.CacheSize);
272+
Assert.AreEqual(cacheTtl, config.CacheTtl);
273+
Assert.IsNull(config.CustomCache);
274+
}
275+
276+
[Test]
277+
public void SetCmabCustomCacheStoresCustomCacheInstance()
278+
{
279+
var customCache = new LruCache<CmabCacheEntry>(maxSize: 10, itemTimeout: TimeSpan.FromMinutes(2));
280+
281+
OptimizelyFactory.SetCmabCustomCache(customCache);
282+
283+
var config = GetCurrentCmabConfiguration();
284+
285+
Assert.IsNotNull(config);
286+
Assert.AreSame(customCache, config.CustomCache);
287+
Assert.IsNull(config.CacheSize);
288+
Assert.IsNull(config.CacheTtl);
289+
}
290+
291+
[Test]
292+
public void NewDefaultInstanceUsesConfiguredCmabCache()
293+
{
294+
const int cacheSize = 7;
295+
var cacheTtl = TimeSpan.FromSeconds(30);
296+
OptimizelyFactory.SetCmabCacheConfig(cacheSize, cacheTtl);
297+
298+
var logger = new NoOpLogger();
299+
var errorHandler = new NoOpErrorHandler();
300+
var projectConfig = DatafileProjectConfig.Create(TestData.Datafile, logger, errorHandler);
301+
var configManager = new FallbackProjectConfigManager(projectConfig);
302+
303+
var optimizely = OptimizelyFactory.NewDefaultInstance(configManager, logger: logger, errorHandler: errorHandler);
304+
305+
var decisionService = Reflection.GetFieldValue<DecisionService, Optimizely>(optimizely, "DecisionService");
306+
Assert.IsNotNull(decisionService);
307+
308+
var cmabService = Reflection.GetFieldValue<ICmabService, DecisionService>(decisionService, "CmabService");
309+
Assert.IsInstanceOf<DefaultCmabService>(cmabService);
310+
311+
var cache = Reflection.GetFieldValue<LruCache<CmabCacheEntry>, DefaultCmabService>((DefaultCmabService)cmabService, "_cmabCache");
312+
Assert.IsNotNull(cache);
313+
Assert.AreEqual(cacheSize, cache.MaxSizeForTesting);
314+
Assert.AreEqual(cacheTtl, cache.TimeoutForTesting);
315+
316+
optimizely.Dispose();
317+
}
318+
319+
private static void ResetCmabConfiguration()
320+
{
321+
var field = typeof(OptimizelyFactory).GetField("CmabConfiguration", BindingFlags.NonPublic | BindingFlags.Static);
322+
field?.SetValue(null, null);
323+
}
324+
325+
private static CmabConfig GetCurrentCmabConfiguration()
326+
{
327+
var field = typeof(OptimizelyFactory).GetField("CmabConfiguration", BindingFlags.NonPublic | BindingFlags.Static);
328+
return field?.GetValue(null) as CmabConfig;
329+
}
247330
}
248331
}

OptimizelySDK/Bucketing/DecisionService.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ OptimizelyDecideOption[] options
312312
// Check if CMAB is properly configured
313313
if (experiment.Cmab == null)
314314
{
315-
var message = string.Format(CmabConstants.CmabExperimentNotProperlyConfigured,
315+
var message = string.Format(CmabConstants.CMAB_EXPERIMENT_NOT_PROPERLY_CONFIGURED,
316316
experiment.Key);
317317
Logger.Log(LogLevel.ERROR, reasons.AddInfo(message));
318318
return Result<VariationDecisionResult>.NewResult(
@@ -337,7 +337,7 @@ OptimizelyDecideOption[] options
337337
var entityId = bucketResult.ResultObject;
338338
if (string.IsNullOrEmpty(entityId))
339339
{
340-
var message = string.Format(CmabConstants.UserNotInCmabExperiment, userId,
340+
var message = string.Format(CmabConstants.USER_NOT_IN_CMAB_EXPERIMENT, userId,
341341
experiment.Key);
342342
Logger.Log(LogLevel.INFO, reasons.AddInfo(message));
343343
return Result<VariationDecisionResult>.NewResult(
@@ -359,7 +359,7 @@ OptimizelyDecideOption[] options
359359

360360
if (cmabDecision == null || string.IsNullOrEmpty(cmabDecision.VariationId))
361361
{
362-
var message = string.Format(CmabConstants.CmabFetchFailed, experiment.Key);
362+
var message = string.Format(CmabConstants.CMAB_FETCH_FAILED, experiment.Key);
363363
Logger.Log(LogLevel.ERROR, reasons.AddInfo(message));
364364
return Result<VariationDecisionResult>.NewResult(
365365
new VariationDecisionResult(null, null, true), reasons);
@@ -378,7 +378,7 @@ OptimizelyDecideOption[] options
378378
new VariationDecisionResult(null), reasons);
379379
}
380380

381-
var successMessage = string.Format(CmabConstants.CmabDecisionFetched, userId,
381+
var successMessage = string.Format(CmabConstants.CMAB_DECISION_FETCHED, userId,
382382
experiment.Key);
383383
Logger.Log(LogLevel.INFO, reasons.AddInfo(successMessage));
384384

@@ -387,7 +387,7 @@ OptimizelyDecideOption[] options
387387
}
388388
catch (Exception ex)
389389
{
390-
var message = string.Format(CmabConstants.CmabFetchFailed, experiment.Key);
390+
var message = string.Format(CmabConstants.CMAB_FETCH_FAILED, experiment.Key);
391391
Logger.Log(LogLevel.ERROR, reasons.AddInfo($"{message} Error: {ex.Message}"));
392392
return Result<VariationDecisionResult>.NewResult(
393393
new VariationDecisionResult(null, null, true), reasons);

OptimizelySDK/Cmab/CmabConfig.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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 OptimizelySDK.Odp;
19+
20+
namespace OptimizelySDK.Cmab
21+
{
22+
/// <summary>
23+
/// Configuration options for CMAB (Contextual Multi-Armed Bandit) functionality.
24+
/// </summary>
25+
public class CmabConfig
26+
{
27+
/// <summary>
28+
/// Initializes a new instance of the CmabConfig class with default cache settings.
29+
/// </summary>
30+
/// <param name="cacheSize">Maximum number of entries in the cache. Default is 1000.</param>
31+
/// <param name="cacheTtl">Time-to-live for cache entries. Default is 30 minutes.</param>
32+
public CmabConfig(int? cacheSize = null, TimeSpan? cacheTtl = null)
33+
{
34+
CacheSize = cacheSize;
35+
CacheTtl = cacheTtl;
36+
CustomCache = null;
37+
}
38+
39+
/// <summary>
40+
/// Initializes a new instance of the CmabConfig class with a custom cache implementation.
41+
/// </summary>
42+
/// <param name="customCache">Custom cache implementation for CMAB decisions.</param>
43+
public CmabConfig(ICache<CmabCacheEntry> customCache)
44+
{
45+
CustomCache = customCache ?? throw new ArgumentNullException(nameof(customCache));
46+
CacheSize = null;
47+
CacheTtl = null;
48+
}
49+
50+
/// <summary>
51+
/// Gets the maximum number of entries in the CMAB cache.
52+
/// If null, the default value (1000) will be used.
53+
/// </summary>
54+
public int? CacheSize { get; }
55+
56+
/// <summary>
57+
/// Gets the time-to-live for CMAB cache entries.
58+
/// If null, the default value (30 minutes) will be used.
59+
/// </summary>
60+
public TimeSpan? CacheTtl { get; }
61+
62+
/// <summary>
63+
/// Gets the custom cache implementation for CMAB decisions.
64+
/// If provided, CacheSize and CacheTtl will be ignored.
65+
/// </summary>
66+
public ICache<CmabCacheEntry> CustomCache { get; }
67+
}
68+
}
Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,46 @@
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-
*/
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+
*/
1616

1717
using System;
1818

1919
namespace OptimizelySDK.Cmab
2020
{
2121
internal static class CmabConstants
2222
{
23-
public const string PredictionUrl = "https://prediction.cmab.optimizely.com/predict";
24-
public static readonly TimeSpan MaxTimeout = TimeSpan.FromSeconds(10);
25-
26-
public const string ContentTypeJson = "application/json";
27-
28-
public const string ErrorFetchFailedFmt = "CMAB decision fetch failed with status: {0}";
29-
public const string ErrorInvalidResponse = "Invalid CMAB fetch response";
30-
public const string ExhaustRetryMessage = "Exhausted all retries for CMAB request";
31-
32-
// Decision service messages
33-
public const string UserNotInCmabExperiment = "User [{0}] not in CMAB experiment [{1}] due to traffic allocation.";
34-
public const string CmabFetchFailed = "Failed to fetch CMAB decision for experiment [{0}].";
35-
public const string CmabDecisionFetched = "CMAB decision fetched for user [{0}] in experiment [{1}].";
36-
public const string CmabExperimentNotProperlyConfigured = "CMAB experiment [{0}] is not properly configured.";
23+
public const string PREDICTION_URL = "https://prediction.cmab.optimizely.com/predict";
24+
public const int DEFAULT_CACHE_SIZE = 10_000;
25+
public const string CONTENT_TYPE = "application/json";
26+
27+
public const string ERROR_FETCH_FAILED_FMT = "CMAB decision fetch failed with status: {0}";
28+
public const string ERROR_INVALID_RESPONSE = "Invalid CMAB fetch response";
29+
public const string EXHAUST_RETRY_MESSAGE = "Exhausted all retries for CMAB request";
30+
31+
public const string USER_NOT_IN_CMAB_EXPERIMENT =
32+
"User [{0}] not in CMAB experiment [{1}] due to traffic allocation.";
33+
34+
public const string CMAB_FETCH_FAILED =
35+
"Failed to fetch CMAB decision for experiment [{0}].";
36+
37+
public const string CMAB_DECISION_FETCHED =
38+
"CMAB decision fetched for user [{0}] in experiment [{1}].";
39+
40+
public const string CMAB_EXPERIMENT_NOT_PROPERLY_CONFIGURED =
41+
"CMAB experiment [{0}] is not properly configured.";
42+
43+
public static readonly TimeSpan MAX_TIMEOUT = TimeSpan.FromSeconds(10);
44+
public static readonly TimeSpan DEFAULT_CACHE_TTL = TimeSpan.FromMinutes(10);
3745
}
3846
}

0 commit comments

Comments
 (0)