Skip to content

feat: Added ODPSegmentManager #321

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 24 commits into from
Nov 23, 2022
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d9db9a5
WIP Initial SegmentManager commit
mikechu-optimizely Nov 4, 2022
e5fcd9a
WIP Initial commit fixes
mikechu-optimizely Nov 4, 2022
61d3e0f
WIP Initial commit fixes
mikechu-optimizely Nov 4, 2022
c4db70a
Finish OdpSegmentManager & interface
mikechu-optimizely Nov 8, 2022
b79861e
WIP unit tests starts
mikechu-optimizely Nov 8, 2022
bc60e1f
Unit tests & Segment Manager edits
mikechu-optimizely Nov 9, 2022
38281ab
Merge branch 'master' into mike/odp-segment-manager
mikechu-optimizely Nov 16, 2022
164b9d6
Merge branch 'master' into mike/odp-segment-manager
mikechu-optimizely Nov 18, 2022
4f62cd7
Fix merge issues; Add unit test
mikechu-optimizely Nov 18, 2022
a614e9f
Lint fixes
mikechu-optimizely Nov 18, 2022
a42ed21
Lint fixes?
mikechu-optimizely Nov 18, 2022
7efe971
Lint fixes??
mikechu-optimizely Nov 18, 2022
8b4f002
Lint fixes???
mikechu-optimizely Nov 18, 2022
3d27572
Remove re-added IOdpConfig.cs
mikechu-optimizely Nov 18, 2022
1c5a914
Add internal doc
mikechu-optimizely Nov 18, 2022
9d61e43
PR code review revisions
mikechu-optimizely Nov 22, 2022
40f2fdf
Update unit test
mikechu-optimizely Nov 22, 2022
3f91f81
Update OptimizelySDK/Odp/OdpSegmentManager.cs
mikechu-optimizely Nov 22, 2022
be49cb1
Pull request code revisions
mikechu-optimizely Nov 22, 2022
892cd3f
Remove time complexity looping/Linq
mikechu-optimizely Nov 23, 2022
77d56dd
Small refactor
mikechu-optimizely Nov 23, 2022
eedf603
Use OrderedDictionary
mikechu-optimizely Nov 23, 2022
ec81354
Use OrderedDictionary
mikechu-optimizely Nov 23, 2022
fe6a6d8
Merge remote-tracking branch 'origin/mike/odp-segment-manager' into m…
mikechu-optimizely Nov 23, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions OptimizelySDK.Tests/OdpTests/OdpSegmentManagerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/*
* 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<string> segmentsToCheck = new List<string>
{
"segment1",
"segment2",
};

private OdpConfig _odpConfig;
private Mock<IOdpSegmentApiManager> _mockApiManager;
private Mock<ILogger> _mockLogger;
private Mock<ICache<List<string>>> _mockCache;

[SetUp]
public void Setup()
{
_odpConfig = new OdpConfig(API_KEY, API_HOST, segmentsToCheck);

_mockApiManager = new Mock<IOdpSegmentApiManager>();

_mockLogger = new Mock<ILogger>();
_mockLogger.Setup(i => i.Log(It.IsAny<LogLevel>(), It.IsAny<string>()));

_mockCache = new Mock<ICache<List<string>>>();
}

[Test]
public void ShouldFetchSegmentsOnCacheMiss()
{
var keyCollector = new List<string>();
_mockCache.Setup(c => c.Lookup(Capture.In(keyCollector)))
.Returns(default(List<string>));
_mockApiManager.Setup(a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()))
.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<List<string>>()), Times.Once);
Assert.AreEqual(segmentsToCheck, segments);
}

[Test]
public void ShouldFetchSegmentsSuccessOnCacheHit()
{
var keyCollector = new List<string>();
_mockCache.Setup(c => c.Lookup(Capture.In(keyCollector)))
.Returns(segmentsToCheck);
_mockApiManager.Setup(a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<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);

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<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()),
Times.Never);
_mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny<List<string>>()), Times.Never);
Assert.AreEqual(segmentsToCheck, segments);
}

[Test]
public void ShouldHandleFetchSegmentsWithError()
{
// OdpSegmentApiManager.FetchSegments() return null on any error
_mockApiManager.Setup(a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()))
.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<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()),
Times.Once);
_mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny<List<string>>()), Times.Once);
Assert.IsNull(segments);
}

[Test]
public void ShouldLogAndReturnNullWhenWhenOdpConfigNotReady()
{
var mockOdpConfig = new Mock<OdpConfig>(API_KEY, API_HOST, new List<string>(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>
{
OdpSegmentOption.IgnoreCache,
});

_mockCache.Verify(c => c.Reset(), Times.Never);
_mockCache.Verify(c => c.Lookup(It.IsAny<string>()), Times.Never);
_mockApiManager.Verify(
a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()),
Times.Once);
_mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny<List<string>>()), 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>
{
OdpSegmentOption.ResetCache,
});

_mockCache.Verify(c => c.Reset(), Times.Once);
_mockCache.Verify(c => c.Lookup(It.IsAny<string>()), Times.Never);
_mockApiManager.Verify(
a => a.FetchSegments(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<OdpUserKeyType>(), It.IsAny<string>(), It.IsAny<List<string>>()),
Times.Once);
_mockCache.Verify(c => c.Save(expectedCacheKey, It.IsAny<List<string>>()), Times.Once);
}

[Test]
public void ShouldMakeValidCacheKey()
{
var keyCollector = new List<string>();
_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);
}
}
}
1 change: 1 addition & 0 deletions OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
<Compile Include="OdpTests\LruCacheTest.cs" />
<Compile Include="OdpTests\OdpEventManagerTests.cs" />
<Compile Include="OdpTests\OdpEventApiManagerTest.cs" />
<Compile Include="OdpTests\OdpSegmentManagerTest.cs" />
<Compile Include="OptimizelyConfigTests\OptimizelyConfigTest.cs" />
<Compile Include="OptimizelyDecisions\OptimizelyDecisionTest.cs" />
<Compile Include="OptimizelyJSONTest.cs" />
Expand Down
10 changes: 10 additions & 0 deletions OptimizelySDK/Odp/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,15 @@ public static class Constants
/// Default amount of time to wait for ODP response
/// </summary>
public static readonly TimeSpan DEFAULT_TIMEOUT_INTERVAL = TimeSpan.FromSeconds(10);

/// <summary>
/// Default maximum number of elements to cache
/// </summary>
public const int DEFAULT_MAX_CACHE_SIZE = 10000;

/// <summary>
/// Default number of minutes to cache
/// </summary>
public const int DEFAULT_CACHE_MINUTES = 10;
}
}
16 changes: 14 additions & 2 deletions OptimizelySDK/Odp/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@

namespace OptimizelySDK.Odp
{
/// <summary>
/// Type of ODP key used for fetching segments & sending events
/// </summary>
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,
}

/// <summary>
/// Options used during segment cache handling
/// </summary>
public enum OdpSegmentOption
{
IgnoreCache = 0,
ResetCache = 1,
}
}
35 changes: 35 additions & 0 deletions OptimizelySDK/Odp/IOdpSegmentManager.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Interface to schedule connections to ODP for audience segmentation and caches the results.
/// </summary>
public interface IOdpSegmentManager
{
/// <summary>
/// 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.
/// </summary>
/// <param name="fsUserId">The FS User ID identifying the user</param>
/// <param name="options">An array of OptimizelySegmentOption used to ignore and/or reset the cache.</param>
/// <returns>Qualified segments for the user from the cache or the ODP server if the cache is empty.</returns>
List<string> FetchQualifiedSegments(string fsUserId, List<OdpSegmentOption> options = null);
}
}
32 changes: 19 additions & 13 deletions OptimizelySDK/Odp/LruCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,6 @@ namespace OptimizelySDK.Odp
public class LruCache<T> : ICache<T>
where T : class
{
/// <summary>
/// Default maximum number of elements to store
/// </summary>
private const int DEFAULT_MAX_SIZE = 10000;

/// <summary>
/// The maximum number of elements that should be stored
/// </summary>
Expand Down Expand Up @@ -66,7 +61,8 @@ public class LruCache<T> : ICache<T>
/// <param name="maxSize">Maximum number of elements to allow in the cache</param>
/// <param name="itemTimeout">Timeout or time to live for each item</param>
/// <param name="logger">Implementation used for recording LRU events or errors</param>
public LruCache(int maxSize = DEFAULT_MAX_SIZE, TimeSpan? itemTimeout = default,
public LruCache(int maxSize = Constants.DEFAULT_MAX_CACHE_SIZE,
TimeSpan? itemTimeout = default,
ILogger logger = null
)
{
Expand All @@ -76,7 +72,7 @@ public LruCache(int maxSize = DEFAULT_MAX_SIZE, TimeSpan? itemTimeout = default,

_logger = logger ?? new DefaultLogger();

_timeout = itemTimeout ?? TimeSpan.FromMinutes(10);
_timeout = itemTimeout ?? TimeSpan.FromMinutes(Constants.DEFAULT_CACHE_MINUTES);
if (_timeout < TimeSpan.Zero)
{
_logger.Log(LogLevel.WARN,
Expand Down Expand Up @@ -118,9 +114,11 @@ public void Save(string key, T value)
{
var leastRecentlyUsedItem = _list.Last;

var leastRecentlyUsedItemKey = (from cacheItem in _cache
where cacheItem.Value == leastRecentlyUsedItem.Value
select cacheItem.Key).FirstOrDefault();
var leastRecentlyUsedItemKey =
_cache.Where(
cacheItem => cacheItem.Value == leastRecentlyUsedItem.Value).
Copy link
Contributor

@jaeopt jaeopt Nov 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this too slow O(n) for every item search when cache is full? Should be O(1). Wondering why your original "OrderedSet"-based one was discarded.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for asking this question. It got me thinking more critically and then researching.


The previous version is LINQ syntactic sugar on top of this new chained version to satisfy the linter. 🤷


Regarding time complexity, as I understood/understand it, you're right the .Where() would be O(N). The index .FirstOrDefault() should allow looping over the collection and stop on the first matching case, giving us O(1) [correct?]

The .Select() will return the matching Key which is what's needed to remove the item from the cache.

cc @msohailhussain

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jaeopt Thinking more on this: LINQ does have its own overhead.

Should I change this to a standard loop?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikechu-optimizely the current looping should be fixed since it'll slow down the replacement search significantly.
Wondering why OrderedSet solution was discarded?
If it can't be used, we can also consider keeping key in the ItemWrapper. See https://github.com/optimizely/swift-sdk/blob/86a328f942da7955873727247ccd675d6b246f80/Sources/ODP/LruCache.swift#L84

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jaeopt I've added the Key as you suggested to ItemWrapper. This allowed me to remove the loop/LINQ. 👍

I'm not unsure I see the OrderedSet. Do you mean these lines that I'm replacing?

 var leastRecentlyUsedItemKey = (from cacheItem in _cache
    where cacheItem.Value == leastRecentlyUsedItem.Value
    select cacheItem.Key).FirstOrDefault();

if so, this LINQ expression is not different from the method chaining version. Happy to discuss on the scheduled meeting I sent for later today so we can close up this PR.

I like the newest solution and hope it meets your standard. If you like the submission and Approve, we can cancel the meeting if you'd like.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikechu-optimizely I saw your origin LRUCache PR uses private readonly OrderedDictionary _orderedDictionary = new OrderedDictionary();. It looked like a good solution and wondering why it was discarded.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohhh. I understand. Thank you for clarifying and digging into the history.

It's likely that I was following the Java implementation before it was updated to use LinkedHashMap.

OrderedDictionary is a better solution. I've updated.

Please review the latest commit.

Select(cacheItem => cacheItem.Key).
FirstOrDefault();

if (leastRecentlyUsedItemKey != null)
{
Expand Down Expand Up @@ -227,9 +225,17 @@ 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 = _list.Join(_cache,
listItem => listItem,
cacheItem => cacheItem.Value,
(listItem, cacheItem) => cacheItem.Key).
ToArray();
}

return cacheKeys;
}
}
}
9 changes: 9 additions & 0 deletions OptimizelySDK/Odp/OdpConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,14 @@ public virtual bool IsReady()
{
return !(string.IsNullOrWhiteSpace(ApiKey) || string.IsNullOrWhiteSpace(ApiHost));
}

/// <summary>
/// Determines if ODP configuration contains segments
/// </summary>
/// <returns></returns>
public bool HasSegments()
{
return SegmentsToCheck?.Count > 0;
}
}
}
Loading