From 3cd67453a9e3589702606b73299ca19b0875a261 Mon Sep 17 00:00:00 2001 From: Mike Chu <104384559+mikechu-optimizely@users.noreply.github.com> Date: Wed, 23 Nov 2022 13:48:26 -0500 Subject: [PATCH] feat: Added ODPSegmentManager (#321) * WIP Initial SegmentManager commit * WIP Initial commit fixes * Finish OdpSegmentManager & interface * WIP unit tests starts * Unit tests & Segment Manager edits to satisfy the unit tests. * Fix merge issues; Add unit test * Lint fixes * Remove re-added IOdpConfig.cs * Add internal doc * PR code review revisions * Update unit test * Update OptimizelySDK/Odp/OdpSegmentManager.cs Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> * Pull request code revisions * Remove time complexity looping/Linq * Small refactor * Use OrderedDictionary Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> --- .../OdpTests/OdpSegmentManagerTest.cs | 228 ++++++++++++++++++ .../OptimizelySDK.Tests.csproj | 1 + OptimizelySDK/Odp/Constants.cs | 10 + OptimizelySDK/Odp/Enums.cs | 16 +- OptimizelySDK/Odp/IOdpSegmentManager.cs | 35 +++ OptimizelySDK/Odp/LruCache.cs | 75 ++---- OptimizelySDK/Odp/OdpConfig.cs | 9 + OptimizelySDK/Odp/OdpSegmentManager.cs | 146 +++++++++++ OptimizelySDK/OptimizelySDK.csproj | 3 + 9 files changed, 472 insertions(+), 51 deletions(-) create mode 100644 OptimizelySDK.Tests/OdpTests/OdpSegmentManagerTest.cs create mode 100644 OptimizelySDK/Odp/IOdpSegmentManager.cs create mode 100644 OptimizelySDK/Odp/OdpSegmentManager.cs diff --git a/OptimizelySDK.Tests/OdpTests/OdpSegmentManagerTest.cs b/OptimizelySDK.Tests/OdpTests/OdpSegmentManagerTest.cs new file mode 100644 index 00000000..c8abe650 --- /dev/null +++ b/OptimizelySDK.Tests/OdpTests/OdpSegmentManagerTest.cs @@ -0,0 +1,228 @@ +/* + * Copyright 2022 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 + * + * https://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 Moq; +using NUnit.Framework; +using OptimizelySDK.AudienceConditions; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Logger; +using OptimizelySDK.Odp; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace OptimizelySDK.Tests.OdpTests +{ + [TestFixture] + public class OdpSegmentManagerTest + { + private const string API_KEY = "S0m3Ap1KEy4U"; + private const string API_HOST = "https://odp-host.example.com"; + private const string FS_USER_ID = "some_valid_user_id"; + + private static readonly string expectedCacheKey = $"fs_user_id-$-{FS_USER_ID}"; + + private static readonly List segmentsToCheck = new List + { + "segment1", + "segment2", + }; + + private OdpConfig _odpConfig; + private Mock _mockApiManager; + private Mock _mockLogger; + private Mock>> _mockCache; + + [SetUp] + public void Setup() + { + _odpConfig = new OdpConfig(API_KEY, API_HOST, segmentsToCheck); + + _mockApiManager = new Mock(); + + _mockLogger = new Mock(); + _mockLogger.Setup(i => i.Log(It.IsAny(), It.IsAny())); + + _mockCache = new Mock>>(); + } + + [Test] + public void ShouldFetchSegmentsOnCacheMiss() + { + var keyCollector = new List(); + _mockCache.Setup(c => c.Lookup(Capture.In(keyCollector))). + Returns(default(List)); + _mockApiManager.Setup(a => a.FetchSegments(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>())). + Returns(segmentsToCheck.ToArray()); + var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object, + Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object); + + var segments = manager.FetchQualifiedSegments(FS_USER_ID); + + var cacheKey = keyCollector.FirstOrDefault(); + Assert.AreEqual(expectedCacheKey, cacheKey); + _mockCache.Verify(c => c.Reset(), Times.Never); + _mockCache.Verify(c => c.Lookup(cacheKey), Times.Once); + _mockLogger.Verify(l => + l.Log(LogLevel.DEBUG, "ODP Cache Miss. Making a call to ODP Server."), Times.Once); + _mockApiManager.Verify( + a => a.FetchSegments( + API_KEY, + API_HOST, + OdpUserKeyType.FS_USER_ID, + FS_USER_ID, + _odpConfig.SegmentsToCheck), Times.Once); + _mockCache.Verify(c => c.Save(cacheKey, It.IsAny>()), Times.Once); + Assert.AreEqual(segmentsToCheck, segments); + } + + [Test] + public void ShouldFetchSegmentsSuccessOnCacheHit() + { + var keyCollector = new List(); + _mockCache.Setup(c => c.Lookup(Capture.In(keyCollector))).Returns(segmentsToCheck); + _mockApiManager.Setup(a => a.FetchSegments(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>())); + var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object, + Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object); + + var segments = manager.FetchQualifiedSegments(FS_USER_ID); + + var cacheKey = keyCollector.FirstOrDefault(); + Assert.AreEqual(expectedCacheKey, cacheKey); + _mockCache.Verify(c => c.Reset(), Times.Never); + _mockCache.Verify(c => c.Lookup(cacheKey), Times.Once); + _mockLogger.Verify(l => + l.Log(LogLevel.DEBUG, "ODP Cache Hit. Returning segments from Cache."), Times.Once); + _mockApiManager.Verify( + a => a.FetchSegments(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>()), + Times.Never); + _mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny>()), Times.Never); + Assert.AreEqual(segmentsToCheck, segments); + } + + [Test] + public void ShouldHandleFetchSegmentsWithError() + { + // OdpSegmentApiManager.FetchSegments() return null on any error + _mockApiManager.Setup(a => a.FetchSegments(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>())). + Returns(null as string[]); + var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object, + Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object); + + var segments = manager.FetchQualifiedSegments(FS_USER_ID); + + _mockCache.Verify(c => c.Reset(), Times.Never); + _mockCache.Verify(c => c.Lookup(expectedCacheKey), Times.Once); + _mockApiManager.Verify( + a => a.FetchSegments(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>()), + Times.Once); + _mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny>()), Times.Never); + Assert.IsNull(segments); + } + + [Test] + public void ShouldLogAndReturnAnEmptySetWhenNoSegmentsToCheck() + { + var odpConfig = new OdpConfig(API_KEY, API_HOST, new List(0)); + var manager = new OdpSegmentManager(odpConfig, _mockApiManager.Object, + Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object); + + var segments = manager.FetchQualifiedSegments(FS_USER_ID); + + Assert.IsTrue(segments.Count == 0); + _mockLogger.Verify( + l => l.Log(LogLevel.DEBUG, + "No Segments are used in the project, Not Fetching segments. Returning empty list."), + Times.Once); + } + + [Test] + public void ShouldLogAndReturnNullWhenOdpConfigNotReady() + { + var mockOdpConfig = new Mock(API_KEY, API_HOST, new List(0)); + mockOdpConfig.Setup(o => o.IsReady()).Returns(false); + var manager = new OdpSegmentManager(mockOdpConfig.Object, _mockApiManager.Object, + Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object); + + var segments = manager.FetchQualifiedSegments(FS_USER_ID); + + Assert.IsNull(segments); + _mockLogger.Verify( + l => l.Log(LogLevel.WARN, Constants.ODP_NOT_INTEGRATED_MESSAGE), + Times.Once); + } + + [Test] + public void ShouldIgnoreCache() + { + var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object, + Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object); + + manager.FetchQualifiedSegments(FS_USER_ID, new List + { + OdpSegmentOption.IgnoreCache, + }); + + _mockCache.Verify(c => c.Reset(), Times.Never); + _mockCache.Verify(c => c.Lookup(It.IsAny()), Times.Never); + _mockApiManager.Verify( + a => a.FetchSegments(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>()), + Times.Once); + _mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny>()), Times.Never); + } + + [Test] + public void ShouldResetCache() + { + var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object, + Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object); + + manager.FetchQualifiedSegments(FS_USER_ID, new List + { + OdpSegmentOption.ResetCache, + }); + + _mockCache.Verify(c => c.Reset(), Times.Once); + _mockCache.Verify(c => c.Lookup(It.IsAny()), Times.Once); + _mockApiManager.Verify( + a => a.FetchSegments(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>()), + Times.Once); + _mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny>()), Times.Once); + } + + [Test] + public void ShouldMakeValidCacheKey() + { + var keyCollector = new List(); + _mockCache.Setup(c => c.Lookup(Capture.In(keyCollector))); + var manager = new OdpSegmentManager(_odpConfig, _mockApiManager.Object, + Constants.DEFAULT_MAX_CACHE_SIZE, null, _mockLogger.Object, _mockCache.Object); + + manager.FetchQualifiedSegments(FS_USER_ID); + + var cacheKey = keyCollector.FirstOrDefault(); + Assert.AreEqual(expectedCacheKey, cacheKey); + } + } +} diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index c1740bab..867c217c 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -85,6 +85,7 @@ + diff --git a/OptimizelySDK/Odp/Constants.cs b/OptimizelySDK/Odp/Constants.cs index 3052996d..9d928699 100644 --- a/OptimizelySDK/Odp/Constants.cs +++ b/OptimizelySDK/Odp/Constants.cs @@ -101,5 +101,15 @@ public static class Constants /// Default amount of time to wait for ODP response /// public static readonly TimeSpan DEFAULT_TIMEOUT_INTERVAL = TimeSpan.FromSeconds(10); + + /// + /// Default maximum number of elements to cache + /// + public const int DEFAULT_MAX_CACHE_SIZE = 10000; + + /// + /// Default number of seconds to cache + /// + public const int DEFAULT_CACHE_SECONDS = 600; } } diff --git a/OptimizelySDK/Odp/Enums.cs b/OptimizelySDK/Odp/Enums.cs index 0209b7a8..7aeb2753 100644 --- a/OptimizelySDK/Odp/Enums.cs +++ b/OptimizelySDK/Odp/Enums.cs @@ -16,11 +16,23 @@ namespace OptimizelySDK.Odp { + /// + /// Type of ODP key used for fetching segments & sending events + /// public enum OdpUserKeyType { // ReSharper disable InconsistentNaming - // ODP expects these names; .ToString() used - VUID = 0, + // ODP expects these names in UPPERCASE; .ToString() used + VUID = 0, // kept for SDK consistency and awareness FS_USER_ID = 1, } + + /// + /// Options used during segment cache handling + /// + public enum OdpSegmentOption + { + IgnoreCache = 0, + ResetCache = 1, + } } diff --git a/OptimizelySDK/Odp/IOdpSegmentManager.cs b/OptimizelySDK/Odp/IOdpSegmentManager.cs new file mode 100644 index 00000000..0a316e24 --- /dev/null +++ b/OptimizelySDK/Odp/IOdpSegmentManager.cs @@ -0,0 +1,35 @@ +/* + * Copyright 2022 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 + * + * https://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.Collections.Generic; + +namespace OptimizelySDK.Odp +{ + /// + /// Interface to schedule connections to ODP for audience segmentation and caches the results. + /// + public interface IOdpSegmentManager + { + /// + /// Attempts to fetch and return a list of a user's qualified segments from the local segments cache. + /// If no cached data exists for the target user, this fetches and caches data from the ODP server instead. + /// + /// The FS User ID identifying the user + /// An array of OptimizelySegmentOption used to ignore and/or reset the cache. + /// Qualified segments for the user from the cache or the ODP server if the cache is empty. + List FetchQualifiedSegments(string fsUserId, List options = null); + } +} diff --git a/OptimizelySDK/Odp/LruCache.cs b/OptimizelySDK/Odp/LruCache.cs index c7b04d86..a475c117 100644 --- a/OptimizelySDK/Odp/LruCache.cs +++ b/OptimizelySDK/Odp/LruCache.cs @@ -17,7 +17,7 @@ using OptimizelySDK.Logger; using OptimizelySDK.Utils; using System; -using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; namespace OptimizelySDK.Odp @@ -25,11 +25,6 @@ namespace OptimizelySDK.Odp public class LruCache : ICache where T : class { - /// - /// Default maximum number of elements to store - /// - private const int DEFAULT_MAX_SIZE = 10000; - /// /// The maximum number of elements that should be stored /// @@ -53,12 +48,7 @@ public class LruCache : ICache /// /// Indexed data held in the cache /// - private readonly Dictionary _cache; - - /// - /// Ordered list of objects being held in the cache - /// - private readonly LinkedList _list; + private readonly OrderedDictionary _cache; /// /// A Least Recently Used in-memory cache @@ -66,17 +56,18 @@ public class LruCache : ICache /// Maximum number of elements to allow in the cache /// Timeout or time to live for each item /// Implementation used for recording LRU events or errors - public LruCache(int maxSize = DEFAULT_MAX_SIZE, TimeSpan? itemTimeout = default, + public LruCache(int? maxSize = null, + TimeSpan? itemTimeout = null, ILogger logger = null ) { _mutex = new object(); - _maxSize = Math.Max(0, maxSize); + _maxSize = Math.Max(0, maxSize ?? Constants.DEFAULT_MAX_CACHE_SIZE); _logger = logger ?? new DefaultLogger(); - _timeout = itemTimeout ?? TimeSpan.FromMinutes(10); + _timeout = itemTimeout ?? TimeSpan.FromSeconds(Constants.DEFAULT_CACHE_SECONDS); if (_timeout < TimeSpan.Zero) { _logger.Log(LogLevel.WARN, @@ -84,9 +75,7 @@ public LruCache(int maxSize = DEFAULT_MAX_SIZE, TimeSpan? itemTimeout = default, _timeout = TimeSpan.Zero; } - _cache = new Dictionary(_maxSize); - - _list = new LinkedList(); + _cache = new OrderedDictionary(_maxSize); } /// @@ -105,34 +94,20 @@ public void Save(string key, T value) lock (_mutex) { - if (_cache.ContainsKey(key)) + if (_cache.Contains(key)) { var item = _cache[key]; - _list.Remove(item); - _list.AddFirst(item); - _cache[key] = item; + _cache.Remove(key); + _cache.Insert(0, key, item); } else { if (_cache.Count >= _maxSize) { - var leastRecentlyUsedItem = _list.Last; - - var leastRecentlyUsedItemKey = (from cacheItem in _cache - where cacheItem.Value == leastRecentlyUsedItem.Value - select cacheItem.Key).FirstOrDefault(); - - if (leastRecentlyUsedItemKey != null) - { - _cache.Remove(leastRecentlyUsedItemKey); - } - - _list.Remove(leastRecentlyUsedItem); + _cache.RemoveAt(_cache.Count - 1); } - var item = new ItemWrapper(value); - _list.AddFirst(item); - _cache.Add(key, item); + _cache.Insert(0, key, new ItemWrapper(value)); } } } @@ -153,28 +128,27 @@ public T Lookup(string key) lock (_mutex) { - if (!_cache.ContainsKey(key)) + if (!_cache.Contains(key)) { return default; } - ItemWrapper item = _cache[key]; - var currentTimestamp = DateTime.Now.MillisecondsSince1970(); + var item = _cache[key] as ItemWrapper; var itemReturn = default(T); - if (_timeout == TimeSpan.Zero || - (currentTimestamp - item.CreationTimestamp < _timeout.TotalMilliseconds)) + if (item != null && (_timeout == TimeSpan.Zero || + currentTimestamp - item.CreationTimestamp < + _timeout.TotalMilliseconds)) { - _list.Remove(item); - _list.AddFirst(item); + _cache.Remove(key); + _cache.Insert(0, key, item); itemReturn = item.Value; } else { _cache.Remove(key); - _list.Remove(item); } return itemReturn; @@ -189,7 +163,6 @@ public void Reset() lock (_mutex) { _cache.Clear(); - _list.Clear(); } } @@ -227,9 +200,13 @@ public string[] _readCurrentCacheKeys() { _logger.Log(LogLevel.WARN, "_readCurrentCacheKeys used for non-testing purpose"); - return (from listItem in _list - join cacheItem in _cache on listItem equals cacheItem.Value - select cacheItem.Key).ToArray(); + string[] cacheKeys; + lock (_mutex) + { + cacheKeys = _cache.Keys.Cast().ToArray(); + } + + return cacheKeys; } } } diff --git a/OptimizelySDK/Odp/OdpConfig.cs b/OptimizelySDK/Odp/OdpConfig.cs index b608e6bd..30127033 100644 --- a/OptimizelySDK/Odp/OdpConfig.cs +++ b/OptimizelySDK/Odp/OdpConfig.cs @@ -108,5 +108,14 @@ public virtual bool IsReady() { return !(string.IsNullOrWhiteSpace(ApiKey) || string.IsNullOrWhiteSpace(ApiHost)); } + + /// + /// Determines if ODP configuration contains segments + /// + /// + public bool HasSegments() + { + return SegmentsToCheck?.Count > 0; + } } } diff --git a/OptimizelySDK/Odp/OdpSegmentManager.cs b/OptimizelySDK/Odp/OdpSegmentManager.cs new file mode 100644 index 00000000..e6bead9d --- /dev/null +++ b/OptimizelySDK/Odp/OdpSegmentManager.cs @@ -0,0 +1,146 @@ +/* + * Copyright 2022 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 + * + * https://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.Logger; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OptimizelySDK.Odp +{ + /// + /// Concrete implementation that schedules connections to ODP for audience segmentation + /// and caches the results. + /// + public class OdpSegmentManager : IOdpSegmentManager + { + /// + /// Logger used to record messages that occur within the ODP client + /// + private readonly ILogger _logger; + + /// + /// ODP segment API manager to communicate with ODP + /// + private readonly IOdpSegmentApiManager _apiManager; + + /// + /// ODP configuration containing the connection parameters + /// + private readonly OdpConfig _odpConfig; + + /// + /// Cached segments + /// + private readonly ICache> _segmentsCache; + + public OdpSegmentManager(OdpConfig odpConfig, IOdpSegmentApiManager apiManager, + int? cacheSize = null, TimeSpan? itemTimeout = null, + ILogger logger = null, ICache> cache = null + ) + { + _apiManager = apiManager; + _odpConfig = odpConfig; + _logger = logger ?? new DefaultLogger(); + + itemTimeout = itemTimeout ?? TimeSpan.FromSeconds(Constants.DEFAULT_CACHE_SECONDS); + if (itemTimeout < TimeSpan.Zero) + { + _logger.Log(LogLevel.WARN, + "Negative item timeout provided. Items will not expire in cache."); + itemTimeout = TimeSpan.Zero; + } + + cacheSize = cacheSize ?? Constants.DEFAULT_MAX_CACHE_SIZE; + + _segmentsCache = + cache ?? new LruCache>(cacheSize.Value, itemTimeout, logger); + } + + /// + /// Attempts to fetch and return a list of a user's qualified segments from the local segments cache. + /// If no cached data exists for the target user, this fetches and caches data from the ODP server instead. + /// + /// The FS User ID identifying the user + /// An array of OptimizelySegmentOption used to ignore and/or reset the cache. + /// Qualified segments for the user from the cache or the ODP server if the cache is empty. + public List FetchQualifiedSegments(string fsUserId, + List options = null + ) + { + if (!_odpConfig.IsReady()) + { + _logger.Log(LogLevel.WARN, Constants.ODP_NOT_INTEGRATED_MESSAGE); + return null; + } + + if (!_odpConfig.HasSegments()) + { + _logger.Log(LogLevel.DEBUG, + "No Segments are used in the project, Not Fetching segments. Returning empty list."); + return new List(); + } + + options = options ?? new List(); + + List qualifiedSegments; + var cacheKey = GetCacheKey(OdpUserKeyType.FS_USER_ID.ToString().ToLower(), fsUserId); + + if (options.Contains(OdpSegmentOption.ResetCache)) + { + _segmentsCache.Reset(); + } + + if (!options.Contains(OdpSegmentOption.IgnoreCache)) + { + qualifiedSegments = _segmentsCache.Lookup(cacheKey); + if (qualifiedSegments != null) + { + _logger.Log(LogLevel.DEBUG, "ODP Cache Hit. Returning segments from Cache."); + return qualifiedSegments; + } + } + + _logger.Log(LogLevel.DEBUG, "ODP Cache Miss. Making a call to ODP Server."); + + qualifiedSegments = _apiManager.FetchSegments( + _odpConfig.ApiKey, + _odpConfig.ApiHost, + OdpUserKeyType.FS_USER_ID, + fsUserId, + _odpConfig.SegmentsToCheck)?. + ToList(); + + if (qualifiedSegments != null && !options.Contains(OdpSegmentOption.IgnoreCache)) + { + _segmentsCache.Save(cacheKey, qualifiedSegments); + } + + return qualifiedSegments; + } + + /// + /// Creates a key used to identify which user fetchQualifiedSegments should lookup and save to in the segments cache + /// + /// Always 'fs_user_id' (parameter for consistency with other SDKs) + /// Arbitrary string representing the full stack user ID + /// Concatenates inputs and returns the string "{userKey}-$-{userValue}" + private static string GetCacheKey(string userKey, string userValue) + { + return $"{userKey}-$-{userValue}"; + } + } +} diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index 00c57b8e..c67269d2 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -101,6 +101,8 @@ + + @@ -110,6 +112,7 @@ +