diff --git a/src/System.Linq.Dynamic.Core/Parser/ConstantExpressionHelper.cs b/src/System.Linq.Dynamic.Core/Parser/ConstantExpressionHelper.cs index 5b651651a..bae556f8e 100644 --- a/src/System.Linq.Dynamic.Core/Parser/ConstantExpressionHelper.cs +++ b/src/System.Linq.Dynamic.Core/Parser/ConstantExpressionHelper.cs @@ -1,29 +1,40 @@ -using System.Collections.Concurrent; +using System.Linq.Dynamic.Core.Util.Cache; +using System.Linq.Dynamic.Core.Validation; using System.Linq.Expressions; -namespace System.Linq.Dynamic.Core.Parser +namespace System.Linq.Dynamic.Core.Parser; + +internal class ConstantExpressionHelper { - internal static class ConstantExpressionHelper + private readonly SlidingCache _expressions; + private readonly SlidingCache _literals; + + public ConstantExpressionHelper(ParsingConfig config) + { + var parsingConfig = Check.NotNull(config); + var useConfig = parsingConfig.ConstantExpressionCacheConfig ?? new CacheConfig(); + + _literals = new SlidingCache(useConfig); + _expressions = new SlidingCache(useConfig); + } + + public bool TryGetText(Expression expression, out string? text) { - private static readonly ConcurrentDictionary Expressions = new(); - private static readonly ConcurrentDictionary Literals = new(); + return _literals.TryGetValue(expression, out text); + } - public static bool TryGetText(Expression expression, out string? text) + public Expression CreateLiteral(object value, string text) + { + if (_expressions.TryGetValue(value, out var outputValue)) { - return Literals.TryGetValue(expression, out text); + return outputValue; } - public static Expression CreateLiteral(object value, string text) - { - if (!Expressions.ContainsKey(value)) - { - ConstantExpression constantExpression = Expression.Constant(value); + var constantExpression = Expression.Constant(value); - Expressions.TryAdd(value, constantExpression); - Literals.TryAdd(constantExpression, text); - } + _expressions.AddOrUpdate(value, constantExpression); + _literals.AddOrUpdate(constantExpression, text); - return Expressions[value]; - } + return constantExpression; } } \ No newline at end of file diff --git a/src/System.Linq.Dynamic.Core/Parser/ConstantExpressionHelperFactory.cs b/src/System.Linq.Dynamic.Core/Parser/ConstantExpressionHelperFactory.cs new file mode 100644 index 000000000..4a3b7ec31 --- /dev/null +++ b/src/System.Linq.Dynamic.Core/Parser/ConstantExpressionHelperFactory.cs @@ -0,0 +1,20 @@ +namespace System.Linq.Dynamic.Core.Parser; + +internal static class ConstantExpressionHelperFactory +{ + private static readonly object Lock = new(); + private static ConstantExpressionHelper? _instance; + + public static ConstantExpressionHelper GetInstance(ParsingConfig config) + { + if (_instance == null) + { + lock (Lock) + { + _instance ??= new ConstantExpressionHelper(config); + } + } + + return _instance; + } +} \ No newline at end of file diff --git a/src/System.Linq.Dynamic.Core/Parser/ExpressionParser.cs b/src/System.Linq.Dynamic.Core/Parser/ExpressionParser.cs index 7e81a3eab..74240e615 100644 --- a/src/System.Linq.Dynamic.Core/Parser/ExpressionParser.cs +++ b/src/System.Linq.Dynamic.Core/Parser/ExpressionParser.cs @@ -45,6 +45,7 @@ public class ExpressionParser private ParameterExpression? _root; private Type? _resultType; private bool _createParameterCtor; + private ConstantExpressionHelper _constantExpressionHelper; /// /// Gets name for the `it` field. By default this is set to the KeyWord value "it". @@ -81,6 +82,7 @@ public ExpressionParser(ParameterExpression[]? parameters, string expression, ob _methodFinder = new MethodFinder(_parsingConfig, _expressionHelper); _typeFinder = new TypeFinder(_parsingConfig, _keywordsHelper); _typeConverterFactory = new TypeConverterFactory(_parsingConfig); + _constantExpressionHelper = ConstantExpressionHelperFactory.GetInstance(_parsingConfig); if (parameters != null) { @@ -900,7 +902,7 @@ private AnyOf ParseStringLiteral(bool forceParseAsString) } _textParser.NextToken(); - return ConstantExpressionHelper.CreateLiteral(parsedStringValue[0], parsedStringValue); + return _constantExpressionHelper.CreateLiteral(parsedStringValue[0], parsedStringValue); } _textParser.NextToken(); @@ -924,7 +926,7 @@ private AnyOf ParseStringLiteral(bool forceParseAsString) parsedStringValue = StringParser.ParseStringAndReplaceDoubleQuotes(text, _textParser.CurrentToken.Pos); - return ConstantExpressionHelper.CreateLiteral(parsedStringValue, parsedStringValue); + return _constantExpressionHelper.CreateLiteral(parsedStringValue, parsedStringValue); } private Expression ParseIntegerLiteral() diff --git a/src/System.Linq.Dynamic.Core/Parser/ExpressionPromoter.cs b/src/System.Linq.Dynamic.Core/Parser/ExpressionPromoter.cs index 67df36325..6c12ad7fa 100644 --- a/src/System.Linq.Dynamic.Core/Parser/ExpressionPromoter.cs +++ b/src/System.Linq.Dynamic.Core/Parser/ExpressionPromoter.cs @@ -9,6 +9,7 @@ namespace System.Linq.Dynamic.Core.Parser public class ExpressionPromoter : IExpressionPromoter { private readonly NumberParser _numberParser; + private readonly ConstantExpressionHelper _constantExpressionHelper; /// /// Initializes a new instance of the class. @@ -17,6 +18,7 @@ public class ExpressionPromoter : IExpressionPromoter public ExpressionPromoter(ParsingConfig config) { _numberParser = new NumberParser(config); + _constantExpressionHelper = ConstantExpressionHelperFactory.GetInstance(config); } /// @@ -48,7 +50,7 @@ public ExpressionPromoter(ParsingConfig config) } else { - if (ConstantExpressionHelper.TryGetText(ce, out var text)) + if (_constantExpressionHelper.TryGetText(ce, out var text)) { Type target = TypeHelper.GetNonNullableType(type); object? value = null; diff --git a/src/System.Linq.Dynamic.Core/Parser/NumberParser.cs b/src/System.Linq.Dynamic.Core/Parser/NumberParser.cs index 791e506c3..c8d7c5d1a 100644 --- a/src/System.Linq.Dynamic.Core/Parser/NumberParser.cs +++ b/src/System.Linq.Dynamic.Core/Parser/NumberParser.cs @@ -16,6 +16,7 @@ public class NumberParser private static readonly char[] Qualifiers = { 'U', 'u', 'L', 'l', 'F', 'f', 'D', 'd', 'M', 'm' }; private static readonly char[] QualifiersHex = { 'U', 'u', 'L', 'l' }; private static readonly string[] QualifiersReal = { "F", "f", "D", "d", "M", "m" }; + private readonly ConstantExpressionHelper _constantExpressionHelper; private readonly CultureInfo _culture; @@ -26,6 +27,7 @@ public class NumberParser public NumberParser(ParsingConfig? config) { _culture = config?.NumberParseCulture ?? CultureInfo.InvariantCulture; + _constantExpressionHelper = ConstantExpressionHelperFactory.GetInstance(config ?? ParsingConfig.Default); } /// @@ -77,12 +79,12 @@ public Expression ParseIntegerLiteral(int tokenPosition, string text) { if (qualifier == "U" || qualifier == "u") { - return ConstantExpressionHelper.CreateLiteral((uint)unsignedValue, text); + return _constantExpressionHelper.CreateLiteral((uint)unsignedValue, text); } if (qualifier == "L" || qualifier == "l") { - return ConstantExpressionHelper.CreateLiteral((long)unsignedValue, text); + return _constantExpressionHelper.CreateLiteral((long)unsignedValue, text); } if (QualifiersReal.Contains(qualifier)) @@ -90,25 +92,25 @@ public Expression ParseIntegerLiteral(int tokenPosition, string text) return ParseRealLiteral(text, qualifier[0], false); } - return ConstantExpressionHelper.CreateLiteral(unsignedValue, text); + return _constantExpressionHelper.CreateLiteral(unsignedValue, text); } if (unsignedValue <= int.MaxValue) { - return ConstantExpressionHelper.CreateLiteral((int)unsignedValue, text); + return _constantExpressionHelper.CreateLiteral((int)unsignedValue, text); } if (unsignedValue <= uint.MaxValue) { - return ConstantExpressionHelper.CreateLiteral((uint)unsignedValue, text); + return _constantExpressionHelper.CreateLiteral((uint)unsignedValue, text); } if (unsignedValue <= long.MaxValue) { - return ConstantExpressionHelper.CreateLiteral((long)unsignedValue, text); + return _constantExpressionHelper.CreateLiteral((long)unsignedValue, text); } - return ConstantExpressionHelper.CreateLiteral(unsignedValue, text); + return _constantExpressionHelper.CreateLiteral(unsignedValue, text); } if (isHexadecimal || isBinary) @@ -135,7 +137,7 @@ public Expression ParseIntegerLiteral(int tokenPosition, string text) { if (qualifier == "L" || qualifier == "l") { - return ConstantExpressionHelper.CreateLiteral(value, text); + return _constantExpressionHelper.CreateLiteral(value, text); } if (QualifiersReal.Contains(qualifier)) @@ -148,10 +150,10 @@ public Expression ParseIntegerLiteral(int tokenPosition, string text) if (value <= int.MaxValue) { - return ConstantExpressionHelper.CreateLiteral((int)value, text); + return _constantExpressionHelper.CreateLiteral((int)value, text); } - return ConstantExpressionHelper.CreateLiteral(value, text); + return _constantExpressionHelper.CreateLiteral(value, text); } /// @@ -163,18 +165,18 @@ public Expression ParseRealLiteral(string text, char qualifier, bool stripQualif { case 'f': case 'F': - return ConstantExpressionHelper.CreateLiteral(ParseNumber(stripQualifier ? text.Substring(0, text.Length - 1) : text, typeof(float))!, text); + return _constantExpressionHelper.CreateLiteral(ParseNumber(stripQualifier ? text.Substring(0, text.Length - 1) : text, typeof(float))!, text); case 'm': case 'M': - return ConstantExpressionHelper.CreateLiteral(ParseNumber(stripQualifier ? text.Substring(0, text.Length - 1) : text, typeof(decimal))!, text); + return _constantExpressionHelper.CreateLiteral(ParseNumber(stripQualifier ? text.Substring(0, text.Length - 1) : text, typeof(decimal))!, text); case 'd': case 'D': - return ConstantExpressionHelper.CreateLiteral(ParseNumber(stripQualifier ? text.Substring(0, text.Length - 1) : text, typeof(double))!, text); + return _constantExpressionHelper.CreateLiteral(ParseNumber(stripQualifier ? text.Substring(0, text.Length - 1) : text, typeof(double))!, text); default: - return ConstantExpressionHelper.CreateLiteral(ParseNumber(text, typeof(double))!, text); + return _constantExpressionHelper.CreateLiteral(ParseNumber(text, typeof(double))!, text); } } @@ -285,12 +287,12 @@ private Expression ParseAsBinary(int tokenPosition, string text, bool isNegative { if (RegexBinary32.IsMatch(text)) { - return ConstantExpressionHelper.CreateLiteral((isNegative ? -1 : 1) * Convert.ToInt32(text, 2), text); + return _constantExpressionHelper.CreateLiteral((isNegative ? -1 : 1) * Convert.ToInt32(text, 2), text); } if (RegexBinary64.IsMatch(text)) { - return ConstantExpressionHelper.CreateLiteral((isNegative ? -1 : 1) * Convert.ToInt64(text, 2), text); + return _constantExpressionHelper.CreateLiteral((isNegative ? -1 : 1) * Convert.ToInt64(text, 2), text); } throw new ParseException(string.Format(_culture, Res.InvalidBinaryIntegerLiteral, text), tokenPosition); diff --git a/src/System.Linq.Dynamic.Core/ParsingConfig.cs b/src/System.Linq.Dynamic.Core/ParsingConfig.cs index 157aadd02..ec3a164a2 100644 --- a/src/System.Linq.Dynamic.Core/ParsingConfig.cs +++ b/src/System.Linq.Dynamic.Core/ParsingConfig.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Linq.Dynamic.Core.CustomTypeProviders; using System.Linq.Dynamic.Core.Parser; +using System.Linq.Dynamic.Core.Util.Cache; namespace System.Linq.Dynamic.Core; @@ -234,4 +235,9 @@ public IQueryableAnalyzer QueryableAnalyzer /// Default value is false. /// public bool DisallowNewKeyword { get; set; } = false; + + /// + /// Caches constant expressions to enhance performance. Periodic cleanup is performed to manage cache size, governed by this configuration. + /// + public CacheConfig? ConstantExpressionCacheConfig { get; set; } } \ No newline at end of file diff --git a/src/System.Linq.Dynamic.Core/Util/Cache/CacheConfig.cs b/src/System.Linq.Dynamic.Core/Util/Cache/CacheConfig.cs new file mode 100644 index 000000000..d1a63a35c --- /dev/null +++ b/src/System.Linq.Dynamic.Core/Util/Cache/CacheConfig.cs @@ -0,0 +1,27 @@ +namespace System.Linq.Dynamic.Core.Util.Cache; + +/// +/// Cache Configuration Options +/// +public class CacheConfig +{ + /// + /// Sets a Time-To-Live (TTL) for items in the constant expression cache to prevent uncontrolled growth. + /// Items not accessed within this TTL will be expired, allowing garbage collection to reclaim the memory. + /// Default is 10 minutes. + /// + public TimeSpan TimeToLive { get; set; } = TimeSpan.FromMinutes(10); + + /// + /// Configures the minimum number of items required in the constant expression cache before triggering cleanup. + /// This prevents frequent cleanups, especially in caches with few items. + /// A default value of null implies that cleanup is always allowed to run, helping in timely removal of unused cache items. + /// + public int? MinItemsTrigger { get; set; } + + /// + /// Sets the frequency for running the cleanup process in the Constant Expression cache. + /// By default, cleanup occurs every 10 minutes. + /// + public TimeSpan CleanupFrequency { get; set; } = TimeSpan.FromMinutes(10); +} \ No newline at end of file diff --git a/src/System.Linq.Dynamic.Core/Util/Cache/CacheEntry.cs b/src/System.Linq.Dynamic.Core/Util/Cache/CacheEntry.cs new file mode 100644 index 000000000..363546c1a --- /dev/null +++ b/src/System.Linq.Dynamic.Core/Util/Cache/CacheEntry.cs @@ -0,0 +1,14 @@ +namespace System.Linq.Dynamic.Core.Util.Cache; + +internal struct CacheEntry where TValue : notnull +{ + public TValue Value { get; } + + public DateTime ExpirationTime { get; } + + public CacheEntry(TValue value, DateTime expirationTime) + { + Value = value; + ExpirationTime = expirationTime; + } +} \ No newline at end of file diff --git a/src/System.Linq.Dynamic.Core/Util/Cache/SlidingCache.cs b/src/System.Linq.Dynamic.Core/Util/Cache/SlidingCache.cs new file mode 100644 index 000000000..5697bc34b --- /dev/null +++ b/src/System.Linq.Dynamic.Core/Util/Cache/SlidingCache.cs @@ -0,0 +1,154 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Dynamic.Core.Validation; + +namespace System.Linq.Dynamic.Core.Util.Cache; + +internal class SlidingCache where TKey : notnull where TValue : notnull +{ + private readonly ConcurrentDictionary> _cache; + private readonly TimeSpan _cleanupFrequency; + private readonly IDateTimeUtils _dateTimeProvider; + private readonly Action _deleteExpiredCachedItemsDelegate; + private readonly long? _minCacheItemsBeforeCleanup; + private DateTime _lastCleanupTime; + + /// + /// Sliding Thread Safe Cache + /// + /// The length of time any object would survive before being removed + /// Only look for expired objects over specific periods + /// + /// If defined, only allow the cleanup process after x number of cached items have + /// been stored + /// + /// + /// Provides the Time for the Caching object. Default will be created if not supplied. Used + /// for Testing classes + /// + public SlidingCache( + TimeSpan timeToLive, + TimeSpan? cleanupFrequency = null, + long? minCacheItemsBeforeCleanup = null, + IDateTimeUtils? dateTimeProvider = null) + { + _cache = new ConcurrentDictionary>(); + TimeToLive = timeToLive; + _minCacheItemsBeforeCleanup = minCacheItemsBeforeCleanup; + _cleanupFrequency = cleanupFrequency ?? SlidingCacheConstants.DefaultCleanupFrequency; + _deleteExpiredCachedItemsDelegate = Cleanup; + _dateTimeProvider = dateTimeProvider ?? new DateTimeUtils(); + // To prevent a scan on first call, set the last Cleanup to the current Provider time + _lastCleanupTime = _dateTimeProvider.UtcNow; + } + + /// + /// Sliding Thread Safe Cache + /// + /// + /// + public SlidingCache( + CacheConfig cashConfig, + IDateTimeUtils? dateTimeProvider = null) + { + _cache = new ConcurrentDictionary>(); + TimeToLive = cashConfig.TimeToLive; + _minCacheItemsBeforeCleanup = cashConfig.MinItemsTrigger; + _cleanupFrequency = cashConfig.CleanupFrequency; + _deleteExpiredCachedItemsDelegate = Cleanup; + _dateTimeProvider = dateTimeProvider ?? new DateTimeUtils(); + // To prevent a scan on first call, set the last Cleanup to the current Provider time + _lastCleanupTime = _dateTimeProvider.UtcNow; + } + + /// + /// Cache TTL value + /// + public TimeSpan TimeToLive { get; } + + /// + /// Provide the number of items in the cache + /// + public int Count => _cache.Count; + + /// + /// Add or update the item in the cache, at the same time update the expiration time + /// + /// + /// + public void AddOrUpdate(TKey key, TValue value) + { + Check.NotNull(key); + Check.NotNull(value); + + var expirationTime = _dateTimeProvider.UtcNow.Add(TimeToLive); + _cache[key] = new CacheEntry(value, expirationTime); + + CleanupIfNeeded(); + } + + /// + /// Attempt to get the value from the cache. This will extend the cache expiration time if the item is found + /// + /// Key + /// Value + /// + public bool TryGetValue(TKey key, [NotNullWhen(true)] out TValue? value) + { + Check.NotNull(key); + + CleanupIfNeeded(); + + if (_cache.TryGetValue(key, out var valueAndExpiration)) + { + if (_dateTimeProvider.UtcNow <= valueAndExpiration.ExpirationTime) + { + value = valueAndExpiration.Value; + var newExpire = _dateTimeProvider.UtcNow.Add(TimeToLive); + _cache[key] = new CacheEntry(value, newExpire); + return true; + } + + // Remove expired item + _cache.TryRemove(key, out _); + } + + value = default; + return false; + } + + public bool Remove(TKey key) + { + Check.NotNull(key); + + var removed = _cache.TryRemove(key, out _); + CleanupIfNeeded(); + return removed; + } + + private void CleanupIfNeeded() + { + if (_dateTimeProvider.UtcNow - _lastCleanupTime > _cleanupFrequency + && (_minCacheItemsBeforeCleanup == null || + _cache.Count >= + _minCacheItemsBeforeCleanup) // Only cleanup if we have a minimum number of items in the cache. + ) + { + // Set here, so we don't have re-entry due to large collection enumeration. + _lastCleanupTime = _dateTimeProvider.UtcNow; + + TaskUtils.Run(_deleteExpiredCachedItemsDelegate); + } + } + + private void Cleanup() + { + foreach (var key in _cache.Keys) + { + if (_dateTimeProvider.UtcNow > _cache[key].ExpirationTime) + { + _cache.TryRemove(key, out _); + } + } + } +} diff --git a/src/System.Linq.Dynamic.Core/Util/Cache/SlidingCacheConstants.cs b/src/System.Linq.Dynamic.Core/Util/Cache/SlidingCacheConstants.cs new file mode 100644 index 000000000..3dd8ee8bd --- /dev/null +++ b/src/System.Linq.Dynamic.Core/Util/Cache/SlidingCacheConstants.cs @@ -0,0 +1,7 @@ +namespace System.Linq.Dynamic.Core.Util.Cache; + +internal static class SlidingCacheConstants +{ + // Default cleanup frequency + public static readonly TimeSpan DefaultCleanupFrequency = TimeSpan.FromMinutes(10); +} diff --git a/src/System.Linq.Dynamic.Core/Util/DateTimeUtils.cs b/src/System.Linq.Dynamic.Core/Util/DateTimeUtils.cs new file mode 100644 index 000000000..6a61abf1f --- /dev/null +++ b/src/System.Linq.Dynamic.Core/Util/DateTimeUtils.cs @@ -0,0 +1,12 @@ +namespace System.Linq.Dynamic.Core.Util +{ + internal interface IDateTimeUtils + { + DateTime UtcNow { get; } + } + + internal class DateTimeUtils : IDateTimeUtils + { + public DateTime UtcNow => DateTime.UtcNow; + } +} diff --git a/src/System.Linq.Dynamic.Core/Util/TaskUtils.cs b/src/System.Linq.Dynamic.Core/Util/TaskUtils.cs new file mode 100644 index 000000000..0654424dc --- /dev/null +++ b/src/System.Linq.Dynamic.Core/Util/TaskUtils.cs @@ -0,0 +1,17 @@ +using System.Linq.Dynamic.Core.Validation; + +namespace System.Linq.Dynamic.Core.Util; + +internal static class TaskUtils +{ + public static void Run(Action action) + { + Check.NotNull(action); +#if NET35 || NET40 + System.Threading.ThreadPool.QueueUserWorkItem(_ => action.Invoke()); +#else + System.Threading.Tasks.Task.Run(action); +#endif + } +} + diff --git a/test/System.Linq.Dynamic.Core.Tests/Util/Cache/SlidingCacheTests.cs b/test/System.Linq.Dynamic.Core.Tests/Util/Cache/SlidingCacheTests.cs new file mode 100644 index 000000000..31402e29f --- /dev/null +++ b/test/System.Linq.Dynamic.Core.Tests/Util/Cache/SlidingCacheTests.cs @@ -0,0 +1,150 @@ +using Moq; +using System.Linq.Dynamic.Core.Util; +using System.Linq.Dynamic.Core.Util.Cache; +using System.Linq.Expressions; +using FluentAssertions; +using Xunit; + +namespace System.Linq.Dynamic.Core.Tests.Util.Cache; + +public class SlidingCacheTests +{ + private static readonly DateTime UtcNow = new(2024, 1, 1, 0, 0, 0); + + [Fact] + public void ThreadSafeSlidingCache_CacheOperations() + { + var dateTimeUtilsMock = new Mock(); + dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow); + + // Arrange + var cache = new SlidingCache( + TimeSpan.FromSeconds(1), + dateTimeProvider: dateTimeUtilsMock.Object); + + // Add + cache.AddOrUpdate(1, "one"); + cache.AddOrUpdate(2, "two"); + cache.AddOrUpdate(3, "three"); + + // Replace + cache.AddOrUpdate(1, "oneone"); + + cache.Count.Should().Be(3, $"Expected 3 items in the cache, only had {cache.Count}"); + + // Test retrieval + Assert.True(cache.TryGetValue(1, out var value1), "Expected to find the value, but did not"); + Assert.True(cache.TryGetValue(2, out var value2), "Expected to find the value, but did not"); + Assert.True(cache.TryGetValue(3, out var value3), "Expected to find the value, but did not"); + + // Test Removal + cache.Remove(1); + cache.Count.Should().Be(2, $"Expected 2 items in the cache, only had {cache.Count}"); + } + + [Fact] + public void ThreadSafeSlidingCache_TestExpire() + { + var dateTimeUtilsMock = new Mock(); + dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow); + + var cache = new SlidingCache(TimeSpan.FromMinutes(10), + dateTimeProvider: dateTimeUtilsMock.Object); + + // Act + cache.AddOrUpdate(1, "one"); + + var newDateTime = dateTimeUtilsMock.Object.UtcNow.AddMinutes(11); + dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(newDateTime); + + if (cache.TryGetValue(1, out var value)) + { + Assert.True(false, $"Expected to not find the value, but found {value}"); + } + } + + [Fact] + public void ThreadSafeSlidingCache_TestAutoExpire() + { + var dateTimeUtilsMock = new Mock(); + dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow); + + // Arrange + var cache = new SlidingCache( + TimeSpan.FromMinutes(10), + dateTimeProvider: dateTimeUtilsMock.Object); + + // Act + cache.AddOrUpdate(1, "one"); + + // Ensure one item is in the cache + cache.Count.Should().Be(1, $"Expected 1 items in the cache, only had {cache.Count}"); + + // move the time forward + var newDateTime = dateTimeUtilsMock.Object.UtcNow.AddMinutes(11); + dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(newDateTime); + + // Trigger the cleanup, asking for non-existing key + cache.TryGetValue(10, out var _); + + // Since the cache cleanup is triggered by a Task and not on the same thread, + // give it a moment for the cleanup to happen + System.Threading.Thread.Sleep(10); + + // Ensure one item is in the cache + cache.Count.Should().Be(0, $"Expected 0 items in the cache, only had {cache.Count}"); + } + + [Fact] + public void ThreadSafeSlidingCache_TestNull() + { + // Arrange + var cache = new SlidingCache(TimeSpan.FromMinutes(10)); + + // Expect an ArgumentNullException + var exception = Assert.Throws(() => { cache.AddOrUpdate(null, "one"); }); + } + + [Fact] + public void ThreadSafeSlidingCache_TestMinNumberBeforeTests() + { + var dateTimeUtilsMock = new Mock(); + dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(UtcNow); + + // Arrange + var cache = new SlidingCache( + TimeSpan.FromMinutes(10), + minCacheItemsBeforeCleanup: 2, + dateTimeProvider: dateTimeUtilsMock.Object); + + // Act + cache.AddOrUpdate(1, "one"); + + // Ensure one item is in the cache + cache.Count.Should().Be(1, $"Expected 1 items in the cache, only had {cache.Count}"); + + // move the time forward + var newDateTime = dateTimeUtilsMock.Object.UtcNow.AddMinutes(11); + dateTimeUtilsMock.SetupGet(d => d.UtcNow).Returns(newDateTime); + + // Trigger the cleanup, asking for non-existing key + cache.TryGetValue(10, out var _); + + // Since the cache cleanup is triggered by a Task and not on the same thread, + // give it a moment for the cleanup to happen + System.Threading.Thread.Sleep(10); + + // Ensure one item is in the cache + cache.Count.Should().Be(1, $"Expected 1 items in the cache, only had {cache.Count}"); + + // Act + cache.AddOrUpdate(2, "two"); + + // Since the cache cleanup is triggered by a Task and not on the same thread, + // give it a moment for the cleanup to happen + System.Threading.Thread.Sleep(10); + + // Ensure one item is in the cache + cache.Count.Should().Be(1, $"Expected 1 items in the cache, had {cache.Count}"); + } +} \ No newline at end of file