Skip to content

Commit

Permalink
Added support (and covering unit tests) for absolute and sliding expi…
Browse files Browse the repository at this point in the history
…ration to the Redis cache entries.
  • Loading branch information
gmcelhanon committed Sep 26, 2023
1 parent bf0dd89 commit f70e4f1
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 50 deletions.
67 changes: 65 additions & 2 deletions Application/EdFi.Ods.Features/ExternalCache/ExternalCacheModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using EdFi.Ods.Common.Caching;
using EdFi.Ods.Common.Configuration;
using EdFi.Ods.Common.Container;
using EdFi.Ods.Features.ExternalCache.Redis;
using Microsoft.Extensions.Caching.Distributed;

namespace EdFi.Ods.Features.ExternalCache
Expand Down Expand Up @@ -112,8 +113,70 @@ public void OverrideDescriptorsCache(ContainerBuilder builder)

public void OverridePersonUniqueIdToUsiCache(ContainerBuilder builder)
{
// TODO: ODS-6016
throw new NotImplementedException("Disable external caching for people until ODS-6016 is implemented.");
if (IsProviderSelected("Redis"))
{
builder.RegisterType<RedisUsiByUniqueIdMapCache>()
.WithParameter(
new ResolvedParameter(
(p, c) => p.Name == "configuration",
(p, c) =>
{
var apiSettings = c.Resolve<ApiSettings>();

return apiSettings.Caching.Redis.Configuration;
}))
.WithParameter(
new ResolvedParameter(
(p, c) => p.Name.Equals("slidingExpirationPeriod", StringComparison.OrdinalIgnoreCase),
(p, c) =>
{
var apiSettings = c.Resolve<ApiSettings>();
int seconds = apiSettings.Caching.PersonUniqueIdToUsi.SlidingExpirationSeconds;
return seconds > 0 ? TimeSpan.FromSeconds(seconds) : null;
}))
.WithParameter(
new ResolvedParameter(
(p, c) => p.Name.Equals("absoluteExpirationPeriod", StringComparison.OrdinalIgnoreCase),
(p, c) =>
{
var apiSettings = c.Resolve<ApiSettings>();
int seconds = apiSettings.Caching.PersonUniqueIdToUsi.AbsoluteExpirationSeconds;
return seconds > 0 ? TimeSpan.FromSeconds(seconds) : null;
}))
.As<IMapCache<(ulong odsInstanceHashId, string personType, PersonMapType mapType), string, int>>()
.SingleInstance();

builder.RegisterType<RedisUniqueIdByUsiMapCache>()
.WithParameter(
new ResolvedParameter(
(p, c) => p.Name == "configuration",
(p, c) =>
{
var apiSettings = c.Resolve<ApiSettings>();

return apiSettings.Caching.Redis.Configuration;
}))
.WithParameter(
new ResolvedParameter(
(p, c) => p.Name.Equals("slidingExpirationPeriod", StringComparison.OrdinalIgnoreCase),
(p, c) =>
{
var apiSettings = c.Resolve<ApiSettings>();
int seconds = apiSettings.Caching.PersonUniqueIdToUsi.SlidingExpirationSeconds;
return seconds > 0 ? TimeSpan.FromSeconds(seconds) : null;
}))
.WithParameter(
new ResolvedParameter(
(p, c) => p.Name.Equals("absoluteExpirationPeriod", StringComparison.OrdinalIgnoreCase),
(p, c) =>
{
var apiSettings = c.Resolve<ApiSettings>();
int seconds = apiSettings.Caching.PersonUniqueIdToUsi.AbsoluteExpirationSeconds;
return seconds > 0 ? TimeSpan.FromSeconds(seconds) : null;
}))
.As<IMapCache<(ulong odsInstanceHashId, string personType, PersonMapType mapType), int, string>>()
.SingleInstance();
}
}
}
}
94 changes: 56 additions & 38 deletions Application/EdFi.Ods.Features/ExternalCache/Redis/RedisMapCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Threading;
using System.Threading.Tasks;
using EdFi.Ods.Api.Caching;
using log4net;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using StackExchange.Redis;

Expand All @@ -22,30 +23,29 @@ namespace EdFi.Ods.Features.ExternalCache.Redis;
/// <typeparam name="TMapValue">The type of the hash's value.</typeparam>
public class RedisMapCache<TKey, TMapKey, TMapValue> : IMapCache<TKey, TMapKey, TMapValue>
{
private readonly TimeSpan? _absoluteExpirationPeriod;
private readonly TimeSpan? _slidingExpirationPeriod;
private readonly RedisCacheOptions _options;
private volatile IConnectionMultiplexer _connection;
private readonly SemaphoreSlim _connectionLock = new(initialCount: 1, maxCount: 1);
private IDatabase _cache;

// TODO: Handle expiration of the cache entry
private const string AbsoluteExpirationKey = "absexp";
private const string SlidingExpirationKey = "sldexp";
private RedisValue[] _expirationKeys = new RedisValue[]
{
AbsoluteExpirationKey,
SlidingExpirationKey
};

// public RedisMapCache(ConfigurationOptions configurationOptions)
// {
// ArgumentNullException.ThrowIfNull(configurationOptions, nameof(configurationOptions));
// _options = new RedisCacheOptions() { ConfigurationOptions = configurationOptions };
// }
private readonly ILog _logger = LogManager.GetLogger(typeof(RedisMapCache<TKey, TMapKey, TMapValue>));

public RedisMapCache(string configuration)
public RedisMapCache(string configuration, TimeSpan? absoluteExpirationPeriod, TimeSpan? slidingExpirationPeriod)
{
ArgumentNullException.ThrowIfNull(configuration, nameof(configuration));

_absoluteExpirationPeriod = absoluteExpirationPeriod;
_slidingExpirationPeriod = slidingExpirationPeriod;

_options = new RedisCacheOptions() { Configuration = configuration };

// Log a warning related to lack of support for sliding expiration
if (slidingExpirationPeriod is { TotalSeconds: > 0 })
{
_logger.Warn($"RedisMapCache is configured with a sliding expiration, but support for this has not yet been implemented.");
}
}

public async Task SetMapEntriesAsync(TKey key, (TMapKey key, TMapValue value)[] mapEntries)
Expand All @@ -72,17 +72,25 @@ public async Task SetMapEntriesAsync(TKey key, (TMapKey key, TMapValue value)[]

return new HashEntry(redisHashKey, redisHashValue);
})
// TODO: Handle sliding expiration refresh of the cache entry
// .Concat(new []
// {
// new HashEntry(AbsoluteExpirationKey, 12435),
// new HashEntry(SlidingExpirationKey, 12435),
// })
.ToArray();

await ConnectAsync().ConfigureAwait(false);

await _cache.HashSetAsync(GetCacheKey(key), hashEntries);

string cacheKey = GetCacheKey(key);
await _cache.HashSetAsync(cacheKey, hashEntries);

if (_absoluteExpirationPeriod is { TotalSeconds: > 0 })
{
// Set initial absolute expiration for the key
_cache.Execute($"EXPIRE", new object[] {
cacheKey,
_absoluteExpirationPeriod.Value.TotalSeconds,
"NX"},
CommandFlags.FireAndForget);
}

// Handle sliding expiration refresh of the cache entry
ApplySlidingExpiration(cacheKey);
}

public async Task<TMapValue[]> GetMapEntriesAsync(TKey key, TMapKey[] mapKeys)
Expand All @@ -94,7 +102,6 @@ public async Task<TMapValue[]> GetMapEntriesAsync(TKey key, TMapKey[] mapKeys)
return Array.Empty<TMapValue>();
}

// TODO: Conditionally add keys for obtaining expiration metadata
var redisHashKeys = mapKeys
.Select(mapKey =>
{
Expand All @@ -113,14 +120,9 @@ public async Task<TMapValue[]> GetMapEntriesAsync(TKey key, TMapKey[] mapKeys)
var keys = redisHashKeys.ToArray();
var hashValues = await _cache.HashGetAsync(cacheKey, keys);

// TODO: Handle sliding expiration refresh of the cache entry
// _cache.HashSetAsync(cacheKey, ...);
// Handle sliding expiration refresh of the cache entry
ApplySlidingExpiration(cacheKey);

foreach (RedisValue hashValue in hashValues)
{
Console.WriteLine(hashValue.ToString());
}

return hashValues.Select(ConvertRedisValue).ToArray();
}

Expand All @@ -136,25 +138,41 @@ public async Task<bool> DeleteMapEntryAsync(TKey key, TMapKey mapKey)

await ConnectAsync().ConfigureAwait(false);

var deleteResult = await _cache.HashDeleteAsync(GetCacheKey(key), redisHashKey);
string cacheKey = GetCacheKey(key);
var deleteResult = await _cache.HashDeleteAsync(cacheKey, redisHashKey);

// TODO: Handle sliding expiration refresh of the cache entry
// _cache.HashSetAsync(cacheKey, ...);
// Handle sliding expiration refresh of the cache entry
ApplySlidingExpiration(cacheKey);

return deleteResult;
}

private async Task ConnectAsync() //CancellationToken token = default(CancellationToken))
private void ApplySlidingExpiration(string cacheKey)
{
// CheckDisposed();
// token.ThrowIfCancellationRequested();
if (_slidingExpirationPeriod is { TotalSeconds: > 0 })
{
// Slide the expiration
_cache.Execute(
$"EXPIRE",
new object[]
{
cacheKey,
_slidingExpirationPeriod.Value.TotalSeconds,
"GT"
},
CommandFlags.FireAndForget);
}
}

private async Task ConnectAsync()
{
if (_cache != null)
{
return;
}

await _connectionLock.WaitAsync().ConfigureAwait(false);

try
{
if (_cache == null)
Expand Down Expand Up @@ -213,7 +231,7 @@ protected virtual void ValidateMapKey(TMapKey mapKey)

protected virtual TMapValue ConvertRedisValue(RedisValue hashValue)
{
return (TMapValue)Convert.ChangeType(hashValue, typeof(TMapValue));
return (TMapValue) Convert.ChangeType(hashValue, typeof(TMapValue));
}

private static bool TryParse<T>(T obj, out RedisValue value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ public abstract class RedisPersonIdentifierMapCache<TMapKey, TMapValue>
private readonly ConcurrentDictionary<(ulong odsInstanceHashId, string personType, PersonMapType personMapType), string>
_cacheKeyAsStringByKey = new();

protected RedisPersonIdentifierMapCache(string configuration)
: base(configuration) { }
protected RedisPersonIdentifierMapCache(string configuration, TimeSpan? absoluteExpirationPeriod, TimeSpan? slidingExpirationPeriod)
: base(configuration, absoluteExpirationPeriod, slidingExpirationPeriod) { }

protected override void ValidateKey((ulong odsInstanceHashId, string personType, PersonMapType personMapType) key)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ namespace EdFi.Ods.Features.ExternalCache.Redis;

public class RedisUniqueIdByUsiMapCache : RedisPersonIdentifierMapCache<int, string>
{
public RedisUniqueIdByUsiMapCache(string configuration)
: base(configuration) { }
public RedisUniqueIdByUsiMapCache(string configuration, TimeSpan? absoluteExpirationPeriod, TimeSpan? slidingExpirationPeriod)
: base(configuration, absoluteExpirationPeriod, slidingExpirationPeriod) { }

protected override string ConvertRedisValue(RedisValue hashValue) => hashValue;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ namespace EdFi.Ods.Features.ExternalCache.Redis;

public class RedisUsiByUniqueIdMapCache : RedisPersonIdentifierMapCache<string, int>
{
public RedisUsiByUniqueIdMapCache(string configuration)
: base(configuration) { }
public RedisUsiByUniqueIdMapCache(string configuration, TimeSpan? absoluteExpirationPeriod, TimeSpan? slidingExpirationPeriod)
: base(configuration, absoluteExpirationPeriod, slidingExpirationPeriod) { }

protected override int ConvertRedisValue(RedisValue hashValue) => (int)hashValue;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,20 @@ public class RedisUniqueIdByUsiMapCacheTests
private RedisUniqueIdByUsiMapCache _mapCache;
private (ulong, string, PersonMapType UsiByUniqueId) _cacheKey;

[SetUp]
private const int AbsoluteExpirationSeconds = 2;
private const int AbsoluteExpirationMs = AbsoluteExpirationSeconds * 1000;

private const int SlidingExpirationSeconds = 1;
private const int SlidingExpirationMs = SlidingExpirationSeconds * 1000;

[OneTimeSetUp]
public void SetUp()
{
_mapCache = new RedisUniqueIdByUsiMapCache("localhost:6379");
_mapCache = new RedisUniqueIdByUsiMapCache(
"localhost:6379",
absoluteExpirationPeriod: TimeSpan.FromSeconds(2),
slidingExpirationPeriod: TimeSpan.FromSeconds(1));

_cacheKey = (123456UL, "Student", PersonMapType.UniqueIdByUsi);
}

Expand Down Expand Up @@ -86,4 +96,69 @@ public async Task DeleteMapEntry_ShouldReturnFalseForNonexistentEntry()
// Assert
deleted.ShouldBeFalse();
}

[Test]
public async Task SetMapEntries_ShouldSetAndRetrieveMultipleMapEntriesUntilAbsoluteExpiration()
{
// Arrange
var mapEntries = new[] { (1, "Value1"), (2, "Value2"), (3, "Value3"), (4, "Extra value") };

// Act I
await _mapCache.SetMapEntriesAsync(_cacheKey, mapEntries);
var retrievedValues = await _mapCache.GetMapEntriesAsync(_cacheKey, new[] { 1, 2, 3 });

// Assert
retrievedValues.ShouldBeEquivalentTo(new[] { "Value1", "Value2", "Value3" });

// Wait for values to expire absolutely
Thread.Sleep(AbsoluteExpirationMs);

// Act II
var retrievedValues2 = await _mapCache.GetMapEntriesAsync(_cacheKey, new[] { 1, 2, 3 });

// Assert II
retrievedValues2.ShouldBeEquivalentTo(new string?[] { null, null, null });
}

[Test]
public async Task SetMapEntries_ShouldSetAndRetrieveMultipleMapEntriesPastAbsoluteExpirationWithSlidingExpiration()
{
// Arrange
var mapEntries = new[] { (1, "Value1"), (2, "Value2"), (3, "Value3"), (4, "Extra value") };

// Act I
await _mapCache.SetMapEntriesAsync(_cacheKey, mapEntries);
var retrievedValues = await _mapCache.GetMapEntriesAsync(_cacheKey, new[] { 1, 2, 3 });

// Assert
retrievedValues.ShouldBeEquivalentTo(new[] { "Value1", "Value2", "Value3" });

// Wait for 200ms past the point where the sliding expiration equals the absolute expiration
Thread.Sleep(AbsoluteExpirationMs - SlidingExpirationMs + 200);

// Act II
// Delete also extends the sliding expiration
var deleteResult = await _mapCache.DeleteMapEntryAsync(_cacheKey, 2);

// Assert
deleteResult.ShouldBeTrue();

// Wait past original absolute expiration, allowing them to *almost* expire through sliding expiration
Thread.Sleep(SlidingExpirationMs - 100);

// Act III
var retrievedValues3 = await _mapCache.GetMapEntriesAsync(_cacheKey, new[] { 1, 2, 3 });

// Assert
retrievedValues3.ShouldBeEquivalentTo(new[] { "Value1", null, "Value3" });

// Now wait for them to expire (past the last sliding expiration)
Thread.Sleep(1025);

// Act IV
var retrievedValues4 = await _mapCache.GetMapEntriesAsync(_cacheKey, new[] { 1, 2, 3 });

// Assert II
retrievedValues4.ShouldBeEquivalentTo(new string?[] { null, null, null });
}
}
Loading

0 comments on commit f70e4f1

Please sign in to comment.