From 511f1285254408eafba673f7926f3ba1b1f645c6 Mon Sep 17 00:00:00 2001 From: Leefrost Date: Thu, 9 Mar 2023 21:36:09 +0200 Subject: [PATCH 01/13] Draft implementation of inmemory cache item based on Microsoft old implementation --- src/HttpClient.Cache/CacheEntryExtensions.cs | 15 + src/HttpClient.Cache/EvictionReason.cs | 11 + src/HttpClient.Cache/InMemory/CacheEntry.cs | 298 +++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 src/HttpClient.Cache/CacheEntryExtensions.cs create mode 100644 src/HttpClient.Cache/EvictionReason.cs create mode 100644 src/HttpClient.Cache/InMemory/CacheEntry.cs diff --git a/src/HttpClient.Cache/CacheEntryExtensions.cs b/src/HttpClient.Cache/CacheEntryExtensions.cs new file mode 100644 index 0000000..590dc7a --- /dev/null +++ b/src/HttpClient.Cache/CacheEntryExtensions.cs @@ -0,0 +1,15 @@ +namespace HttpClient.Cache; + +public static class CacheEntryExtensions +{ + public static ICacheEntry AddExpirationToken(this ICacheEntry entry, IChangeToken token) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + entry.ExpirationTokens.Add(token); + return entry; + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/EvictionReason.cs b/src/HttpClient.Cache/EvictionReason.cs new file mode 100644 index 0000000..f707301 --- /dev/null +++ b/src/HttpClient.Cache/EvictionReason.cs @@ -0,0 +1,11 @@ +namespace HttpClient.Cache; + +public enum EvictionReason +{ + None, + Removed, + Replaced, + Expired, + TokenExpired, + Capacity +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/CacheEntry.cs b/src/HttpClient.Cache/InMemory/CacheEntry.cs new file mode 100644 index 0000000..003d1e7 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/CacheEntry.cs @@ -0,0 +1,298 @@ +using System.Diagnostics; + +namespace HttpClient.Cache.InMemory; + +internal class CacheEntry : ICacheEntry +{ + private readonly object _lock = new(); + + private TimeSpan? _slidingExpiration; + private DateTimeOffset? _absoluteExpiration; + private TimeSpan? _absoluteExpirationRelativeToNow; + + private IList _expirationTokenRegistrations = default!; + private IList _expirationTokens = default!; + private IList _postEvictionCallbacks = default!; + + private readonly Action _notifyCacheEntryDisposed; + private readonly Action _notifyCacheOfExpiration; + + private static readonly Action ExpirationCallback = ExpirationTokensExpired; + + private bool _isAdded; + private bool _isExpired; + + internal CacheEntry(object key, Action notifyCacheEntryDisposed, + Action notifyCacheOfExpiration) + { + Key = key ?? throw new ArgumentNullException(nameof(key)); + + _notifyCacheEntryDisposed = notifyCacheEntryDisposed ?? + throw new ArgumentNullException(nameof(notifyCacheEntryDisposed)); + _notifyCacheOfExpiration = + notifyCacheOfExpiration ?? throw new ArgumentNullException(nameof(notifyCacheOfExpiration)); + } + + public object Key { get; } + public object Value { get; set; } + + public CacheItemPriority Priority { get; set; } = CacheItemPriority.Normal; + + public DateTimeOffset? AbsoluteExpiration + { + get => _absoluteExpiration; + set => _absoluteExpiration = value; + } + + public TimeSpan? AbsoluteExpirationRelativeToNow + { + get => _absoluteExpirationRelativeToNow; + set + { + if ((value.HasValue ? value.GetValueOrDefault() <= TimeSpan.Zero ? 1 : 0 : 0) != 0) + { + throw new ArgumentOutOfRangeException(nameof(AbsoluteExpirationRelativeToNow), value, + "The relative expiration must be positive"); + } + + _absoluteExpirationRelativeToNow = value; + } + } + + public TimeSpan? SlidingExpiration + { + get => _slidingExpiration; + set + { + if ((value.HasValue ? value.GetValueOrDefault() <= TimeSpan.Zero ? 1 : 0 : 0) != 0) + { + throw new ArgumentOutOfRangeException(nameof(SlidingExpiration), value, + "The sliding expiration must be positive"); + } + + _slidingExpiration = value; + } + } + + public IList ExpirationTokens => _expirationTokens; + + public IList PostEvictionCallbacks => _postEvictionCallbacks; + + internal DateTimeOffset LastAccessed { get; set; } + internal EvictionReason EvictionReason { get; private set; } + + internal bool IsExpired(DateTimeOffset now) + { + if (_isExpired && !CheckForExpiredTime(now)) + { + return CheckForExpiredTokens(); + } + + return true; + } + + private bool CheckForExpiredTime(DateTimeOffset now) + { + if (_absoluteExpiration.HasValue && _absoluteExpiration.Value <= now) + { + SetExpired(EvictionReason.Expired); + return true; + } + + if (_slidingExpiration.HasValue) + { + TimeSpan timeSpan = now - LastAccessed; + TimeSpan? slidingWindows = _slidingExpiration; + if ((slidingWindows.HasValue ? timeSpan >= slidingWindows.GetValueOrDefault() ? 1 : 0 : 0) != 0) + { + SetExpired(EvictionReason.Expired); + return true; + } + } + + return false; + } + + internal void SetExpired(EvictionReason reason) + { + if (EvictionReason == null) + { + EvictionReason = reason; + } + + _isExpired = true; + DetachTokens(); + } + + internal bool CheckForExpiredTokens() + { + if (_expirationTokens != null) + { + for (int index = 0; index < _expirationTokens.Count; ++index) + { + if (_expirationTokens[index].HasChanged) + { + SetExpired(EvictionReason.TokenExpired); + return true; + } + } + } + + return false; + } + + internal void AttachTokens() + { + if (_expirationTokens == null) + { + return; + } + + lock (_lock) + { + for (int index = 0; index < _expirationTokens.Count; ++index) + { + IChangeToken token = _expirationTokens[index]; + if (token.ActiveChangeCallbacks) + { + if (_expirationTokenRegistrations == null) + { + _expirationTokenRegistrations = new List(); + } + + _expirationTokenRegistrations.Add(token.RegisterChangeCallback(ExpirationCallback, this)); + } + } + } + } + + private static void ExpirationTokensExpired(object obj) + { + Task.Factory.StartNew(state => + { + CacheEntry? cacheEntry = (CacheEntry)state; + cacheEntry.SetExpired(EvictionReason.TokenExpired); + cacheEntry._notifyCacheOfExpiration(cacheEntry); + }, obj, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + + internal void DetachTokens() + { + lock (_lock) + { + IList? registrations = _expirationTokenRegistrations; + if (registrations == null) + { + return; + } + + _expirationTokenRegistrations = null; + foreach (IDisposable registration in registrations) + { + registration.Dispose(); + } + } + } + + internal void InvokeEvictionCallbacks() + { + if (_postEvictionCallbacks == null) + { + return; + } + + TaskFactory factory = Task.Factory; + CancellationToken token = CancellationToken.None; + TaskScheduler scheduler = TaskScheduler.Default; + factory.StartNew(state => InvokeCallbacks((CacheEntry)state), this, token, TaskCreationOptions.DenyChildAttach, + scheduler); + } + + private static void InvokeCallbacks(CacheEntry entry) + { + IList? callbackRegistrationList = + Interlocked.Exchange(ref entry._postEvictionCallbacks, null); + + if (callbackRegistrationList == null) + { + return; + } + + foreach (PostEvictionCallbackRegistration postEvictionCallbackRegistration in callbackRegistrationList) + { + try + { + PostEvictionDelegate? evictionCallback = postEvictionCallbackRegistration.EvictionCallback; + if (evictionCallback != null) + { + object key = entry.Key; + object value = entry.Value; + EvictionReason reason = entry.EvictionReason; + object state = postEvictionCallbackRegistration.State; + evictionCallback.Invoke(key, value, reason.ToString(), state); + } + } + catch (Exception e) + { + Debug.WriteLine($"{e}"); + } + } + } + + internal void PropagateOptions(CacheEntry parent) + { + if (parent == null) + { + return; + } + + if (_expirationTokens != null) + { + lock (_lock) + { + lock (parent._lock) + { + using (IEnumerator changeTokenEnumerator = _expirationTokens.GetEnumerator()) + { + while (changeTokenEnumerator.MoveNext()) + { + IChangeToken changeToken = changeTokenEnumerator.Current; + parent.AddExpirationToken(changeToken); + } + } + } + } + } + + if (!_absoluteExpiration.HasValue) + { + return; + } + + if (parent._absoluteExpiration.HasValue) + { + DateTimeOffset? expiration = _absoluteExpiration; + DateTimeOffset? parentExpiration = parent._absoluteExpiration; + if ((expiration.HasValue & parentExpiration.HasValue + ? expiration.GetValueOrDefault() < parentExpiration.GetValueOrDefault() ? 1 : 0 + : 0) == 0) + { + return; + } + } + + parent._absoluteExpiration = _absoluteExpiration; + } + + public void Dispose() + { + if (_isAdded) + { + return; + } + + _isAdded = true; + _notifyCacheEntryDisposed(this); + } +} \ No newline at end of file From 7e6f95641d45817500c3bef97440b986a2553b7a Mon Sep 17 00:00:00 2001 From: Leefrost Date: Fri, 10 Mar 2023 22:22:11 +0200 Subject: [PATCH 02/13] Added memory cache basic raw implementation --- src/HttpClient.Cache/InMemory/CacheEntry.cs | 254 +++++++---------- .../InMemory/Clock/ISystemClock.cs | 6 + .../InMemory/Clock/SystemClock.cs | 6 + src/HttpClient.Cache/InMemory/IMemoryCache.cs | 12 + src/HttpClient.Cache/InMemory/MemoryCache.cs | 265 ++++++++++++++++++ .../InMemory/MemoryCacheOptions.cs | 10 + .../PostEvictionCallbackRegistration.cs | 7 +- 7 files changed, 411 insertions(+), 149 deletions(-) create mode 100644 src/HttpClient.Cache/InMemory/Clock/ISystemClock.cs create mode 100644 src/HttpClient.Cache/InMemory/Clock/SystemClock.cs create mode 100644 src/HttpClient.Cache/InMemory/IMemoryCache.cs create mode 100644 src/HttpClient.Cache/InMemory/MemoryCache.cs create mode 100644 src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs diff --git a/src/HttpClient.Cache/InMemory/CacheEntry.cs b/src/HttpClient.Cache/InMemory/CacheEntry.cs index 003d1e7..8b14ad8 100644 --- a/src/HttpClient.Cache/InMemory/CacheEntry.cs +++ b/src/HttpClient.Cache/InMemory/CacheEntry.cs @@ -5,28 +5,26 @@ namespace HttpClient.Cache.InMemory; internal class CacheEntry : ICacheEntry { private readonly object _lock = new(); + private static readonly Action ExpirationCallback = ExpirationTokensExpired; - private TimeSpan? _slidingExpiration; - private DateTimeOffset? _absoluteExpiration; - private TimeSpan? _absoluteExpirationRelativeToNow; - - private IList _expirationTokenRegistrations = default!; - private IList _expirationTokens = default!; - private IList _postEvictionCallbacks = default!; - private readonly Action _notifyCacheEntryDisposed; private readonly Action _notifyCacheOfExpiration; + private DateTimeOffset? _absoluteExpiration; + private TimeSpan? _absoluteExpirationRelativeToNow; + private TimeSpan? _slidingExpiration; - private static readonly Action ExpirationCallback = ExpirationTokensExpired; - - private bool _isAdded; + private IList? _expirationTokenRegistrations; + private IList? _expirationTokens; + private IList? _postEvictionCallbacks; + + private bool _isDisposed; private bool _isExpired; internal CacheEntry(object key, Action notifyCacheEntryDisposed, Action notifyCacheOfExpiration) { Key = key ?? throw new ArgumentNullException(nameof(key)); - + _notifyCacheEntryDisposed = notifyCacheEntryDisposed ?? throw new ArgumentNullException(nameof(notifyCacheEntryDisposed)); _notifyCacheOfExpiration = @@ -35,7 +33,7 @@ internal CacheEntry(object key, Action notifyCacheEntryDisposed, public object Key { get; } public object Value { get; set; } - + public CacheItemPriority Priority { get; set; } = CacheItemPriority.Normal; public DateTimeOffset? AbsoluteExpiration @@ -74,71 +72,105 @@ public TimeSpan? SlidingExpiration } } - public IList ExpirationTokens => _expirationTokens; - - public IList PostEvictionCallbacks => _postEvictionCallbacks; + public IList ExpirationTokens + { + get + { + return _expirationTokens ??= new List(); + } + } + public IList PostEvictionCallbacks => + _postEvictionCallbacks ?? new List(); + internal DateTimeOffset LastAccessed { get; set; } + internal EvictionReason EvictionReason { get; private set; } - - internal bool IsExpired(DateTimeOffset now) + + public void Dispose() { - if (_isExpired && !CheckForExpiredTime(now)) + if (_isDisposed) { - return CheckForExpiredTokens(); + return; } + _isDisposed = true; + _notifyCacheEntryDisposed(this); + } + + internal bool IsExpired(DateTimeOffset currentTime) + { + if (_isExpired && !IsExpiredNow(currentTime)) + { + return IsAnyExpirationTokenExpired(); + } return true; } - private bool CheckForExpiredTime(DateTimeOffset now) + private bool IsExpiredNow(DateTimeOffset currentTime) { - if (_absoluteExpiration.HasValue && _absoluteExpiration.Value <= now) + if (_absoluteExpiration.HasValue && _absoluteExpiration.Value <= currentTime) { - SetExpired(EvictionReason.Expired); + ExpireEntryByReason(EvictionReason.Expired); return true; } - if (_slidingExpiration.HasValue) + if (!_slidingExpiration.HasValue) + { + return false; + } + + var accessedOffset = currentTime - LastAccessed; + var expiration = _slidingExpiration; + if ((expiration.HasValue ? accessedOffset >= expiration.GetValueOrDefault() ? 1 : 0 : 0) == 0) { - TimeSpan timeSpan = now - LastAccessed; - TimeSpan? slidingWindows = _slidingExpiration; - if ((slidingWindows.HasValue ? timeSpan >= slidingWindows.GetValueOrDefault() ? 1 : 0 : 0) != 0) + return false; + } + + ExpireEntryByReason(EvictionReason.Expired); + return true; + } + + private bool IsAnyExpirationTokenExpired() + { + if (_expirationTokens == null) + { + return false; + } + + foreach (var token in _expirationTokens) + { + if (!token.HasChanged) { - SetExpired(EvictionReason.Expired); - return true; + continue; } + + ExpireEntryByReason(EvictionReason.TokenExpired); + return true; } return false; } - - internal void SetExpired(EvictionReason reason) + + private static void ExpirationTokensExpired(object entry) { - if (EvictionReason == null) + Task.Factory.StartNew(state => { - EvictionReason = reason; - } - - _isExpired = true; - DetachTokens(); + CacheEntry? cacheEntry = state as CacheEntry; + cacheEntry?.ExpireEntryByReason(EvictionReason.TokenExpired); + cacheEntry?._notifyCacheOfExpiration(cacheEntry); + }, entry, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); } - internal bool CheckForExpiredTokens() + internal void ExpireEntryByReason(EvictionReason reason) { - if (_expirationTokens != null) + if (EvictionReason == EvictionReason.None) { - for (int index = 0; index < _expirationTokens.Count; ++index) - { - if (_expirationTokens[index].HasChanged) - { - SetExpired(EvictionReason.TokenExpired); - return true; - } - } + EvictionReason = reason; } - return false; + _isExpired = true; + DetachTokens(); } internal void AttachTokens() @@ -150,51 +182,37 @@ internal void AttachTokens() lock (_lock) { - for (int index = 0; index < _expirationTokens.Count; ++index) + foreach (var token in _expirationTokens) { - IChangeToken token = _expirationTokens[index]; - if (token.ActiveChangeCallbacks) + if (!token.ActiveChangeCallbacks) { - if (_expirationTokenRegistrations == null) - { - _expirationTokenRegistrations = new List(); - } - - _expirationTokenRegistrations.Add(token.RegisterChangeCallback(ExpirationCallback, this)); + continue; } + + _expirationTokenRegistrations ??= new List(); + _expirationTokenRegistrations.Add(token.RegisterChangeCallback(ExpirationCallback, this)); } } } - - private static void ExpirationTokensExpired(object obj) - { - Task.Factory.StartNew(state => - { - CacheEntry? cacheEntry = (CacheEntry)state; - cacheEntry.SetExpired(EvictionReason.TokenExpired); - cacheEntry._notifyCacheOfExpiration(cacheEntry); - }, obj, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); - } - - + internal void DetachTokens() { lock (_lock) { - IList? registrations = _expirationTokenRegistrations; - if (registrations == null) + var tokenRegistrations = _expirationTokenRegistrations; + if (tokenRegistrations == null) { return; } _expirationTokenRegistrations = null; - foreach (IDisposable registration in registrations) + foreach (IDisposable registration in tokenRegistrations) { registration.Dispose(); } } } - + internal void InvokeEvictionCallbacks() { if (_postEvictionCallbacks == null) @@ -202,97 +220,41 @@ internal void InvokeEvictionCallbacks() return; } - TaskFactory factory = Task.Factory; - CancellationToken token = CancellationToken.None; - TaskScheduler scheduler = TaskScheduler.Default; - factory.StartNew(state => InvokeCallbacks((CacheEntry)state), this, token, TaskCreationOptions.DenyChildAttach, - scheduler); + Task.Factory.StartNew(state => InvokeCallbacks((CacheEntry)state), this, CancellationToken.None, + TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); } private static void InvokeCallbacks(CacheEntry entry) { - IList? callbackRegistrationList = + var evictionCallbacks = Interlocked.Exchange(ref entry._postEvictionCallbacks, null); - - if (callbackRegistrationList == null) + + if (evictionCallbacks == null) { return; } - foreach (PostEvictionCallbackRegistration postEvictionCallbackRegistration in callbackRegistrationList) + foreach (var callback in evictionCallbacks) { try { - PostEvictionDelegate? evictionCallback = postEvictionCallbackRegistration.EvictionCallback; - if (evictionCallback != null) + var evictionCallback = callback.EvictionCallback; + if (evictionCallback == null) { - object key = entry.Key; - object value = entry.Value; - EvictionReason reason = entry.EvictionReason; - object state = postEvictionCallbackRegistration.State; - evictionCallback.Invoke(key, value, reason.ToString(), state); + continue; } - } - catch (Exception e) - { - Debug.WriteLine($"{e}"); - } - } - } - internal void PropagateOptions(CacheEntry parent) - { - if (parent == null) - { - return; - } - - if (_expirationTokens != null) - { - lock (_lock) - { - lock (parent._lock) - { - using (IEnumerator changeTokenEnumerator = _expirationTokens.GetEnumerator()) - { - while (changeTokenEnumerator.MoveNext()) - { - IChangeToken changeToken = changeTokenEnumerator.Current; - parent.AddExpirationToken(changeToken); - } - } - } + object key = entry.Key; + object? value = entry.Value; + EvictionReason reason = entry.EvictionReason; + object? state = callback.State; + + evictionCallback.Invoke(key, value, reason.ToString(), state); } - } - - if (!_absoluteExpiration.HasValue) - { - return; - } - - if (parent._absoluteExpiration.HasValue) - { - DateTimeOffset? expiration = _absoluteExpiration; - DateTimeOffset? parentExpiration = parent._absoluteExpiration; - if ((expiration.HasValue & parentExpiration.HasValue - ? expiration.GetValueOrDefault() < parentExpiration.GetValueOrDefault() ? 1 : 0 - : 0) == 0) + catch (Exception ex) { - return; + Debug.WriteLine($"{ex}"); } } - - parent._absoluteExpiration = _absoluteExpiration; - } - - public void Dispose() - { - if (_isAdded) - { - return; - } - - _isAdded = true; - _notifyCacheEntryDisposed(this); } } \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/Clock/ISystemClock.cs b/src/HttpClient.Cache/InMemory/Clock/ISystemClock.cs new file mode 100644 index 0000000..8662ee2 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/Clock/ISystemClock.cs @@ -0,0 +1,6 @@ +namespace HttpClient.Cache.InMemory.Clock; + +public interface ISystemClock +{ + DateTimeOffset UtcNow { get; } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/Clock/SystemClock.cs b/src/HttpClient.Cache/InMemory/Clock/SystemClock.cs new file mode 100644 index 0000000..8cbccef --- /dev/null +++ b/src/HttpClient.Cache/InMemory/Clock/SystemClock.cs @@ -0,0 +1,6 @@ +namespace HttpClient.Cache.InMemory.Clock; + +public class SystemClock: ISystemClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/IMemoryCache.cs b/src/HttpClient.Cache/InMemory/IMemoryCache.cs new file mode 100644 index 0000000..b054304 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/IMemoryCache.cs @@ -0,0 +1,12 @@ +namespace HttpClient.Cache.InMemory; + +public interface IMemoryCache: IDisposable +{ + bool TryGetValue(object key, out object? value); + + ICacheEntry CreateEntry(object key); + + void Remove(object key); + + void Clear(); +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/MemoryCache.cs b/src/HttpClient.Cache/InMemory/MemoryCache.cs new file mode 100644 index 0000000..28e9b25 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/MemoryCache.cs @@ -0,0 +1,265 @@ +using System.Collections.Concurrent; +using HttpClient.Cache.InMemory.Clock; + +namespace HttpClient.Cache.InMemory; + +public class MemoryCache : IMemoryCache +{ + private readonly ISystemClock _clock; + private readonly ConcurrentDictionary _cacheEntries; + private readonly Action _entryExpirationNotification; + + private readonly TimeSpan _expirationScanFrequency; + private readonly Action _setEntry; + + private bool _isDisposed; + private DateTimeOffset _lastExpirationScan; + + private ICollection> CacheEntries => _cacheEntries; + + public MemoryCache() + : this(new MemoryCacheOptions()) + { + } + + public MemoryCache(MemoryCacheOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + _cacheEntries = new ConcurrentDictionary(); + _setEntry = SetEntry; + _entryExpirationNotification = EntryExpired; + + _clock = options.Clock; + _expirationScanFrequency = options.ExpirationScanFrequency; + _lastExpirationScan = _clock.UtcNow; + } + + ~MemoryCache() + { + Dispose(false); + } + + public ICacheEntry CreateEntry(object key) + { + if (_isDisposed) + { + throw new ObjectDisposedException(typeof(MemoryCache).FullName); + } + + return new CacheEntry(key, _setEntry, _entryExpirationNotification); + } + + public bool TryGetValue(object key, out object? value) + { + if (_isDisposed) + { + throw new ObjectDisposedException(typeof(MemoryCache).FullName); + } + + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + value = null; + var currentTime = _clock.UtcNow; + + bool isEntryFound = false; + if (_cacheEntries.TryGetValue(key, out CacheEntry? entry)) + { + if (entry.IsExpired(currentTime) && entry.EvictionReason != EvictionReason.Replaced) + { + RemoveEntry(entry); + } + else + { + isEntryFound = true; + entry.LastAccessed = currentTime; + value = entry.Value; + } + } + + StartScanForExpiredItems(); + return isEntryFound; + } + + public void Remove(object key) + { + if (_isDisposed) + { + throw new ObjectDisposedException(typeof(MemoryCache).FullName); + } + + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (_cacheEntries.TryRemove(key, out CacheEntry? cacheEntry)) + { + cacheEntry.ExpireEntryByReason(EvictionReason.Removed); + cacheEntry.InvokeEvictionCallbacks(); + } + + StartScanForExpiredItems(); + } + + public void Clear() + { + if (_isDisposed) + { + throw new ObjectDisposedException(typeof(MemoryCache).FullName); + } + + var keys = _cacheEntries.Keys.ToList(); + foreach (object key in keys) + { + if (_cacheEntries.TryRemove(key, out CacheEntry? cacheEntry)) + { + cacheEntry.ExpireEntryByReason(EvictionReason.Removed); + cacheEntry.InvokeEvictionCallbacks(); + } + } + + StartScanForExpiredItems(); + } + + public void Dispose() + { + Dispose(true); + } + + private void SetEntry(CacheEntry entry) + { + if (_isDisposed) + { + return; + } + + DateTimeOffset currentTime = _clock.UtcNow; + DateTimeOffset? entryAbsoluteExpiration = new(); + + if (entry.AbsoluteExpirationRelativeToNow.HasValue) + { + TimeSpan? expirationRelativeToNow = entry.AbsoluteExpirationRelativeToNow; + entryAbsoluteExpiration = currentTime + expirationRelativeToNow; + } + else if (entry.AbsoluteExpiration.HasValue) + { + entryAbsoluteExpiration = entry.AbsoluteExpiration; + } + + if (entryAbsoluteExpiration.HasValue && + (!entry.AbsoluteExpiration.HasValue || entryAbsoluteExpiration.Value < entry.AbsoluteExpiration.Value)) + { + entry.AbsoluteExpiration = entryAbsoluteExpiration; + } + + entry.LastAccessed = currentTime; + + if (_cacheEntries.TryGetValue(entry.Key, out CacheEntry? cacheEntry)) + { + cacheEntry.ExpireEntryByReason(EvictionReason.Replaced); + } + + if (!entry.IsExpired(currentTime)) + { + bool isEntryAdded; + if (cacheEntry == null) + { + isEntryAdded = _cacheEntries.TryAdd(entry.Key, entry); + } + else + { + isEntryAdded = _cacheEntries.TryUpdate(entry.Key, entry, cacheEntry); + if (!isEntryAdded) + { + isEntryAdded = _cacheEntries.TryAdd(entry.Key, entry); + } + } + + if (isEntryAdded) + { + entry.AttachTokens(); + } + else + { + entry.ExpireEntryByReason(EvictionReason.Replaced); + entry.InvokeEvictionCallbacks(); + } + + cacheEntry?.InvokeEvictionCallbacks(); + } + else + { + entry.InvokeEvictionCallbacks(); + if (cacheEntry != null) + { + RemoveEntry(cacheEntry); + } + } + + StartScanForExpiredItems(); + } + + private void RemoveEntry(CacheEntry entry) + { + if (!CacheEntries.Remove(new KeyValuePair(entry.Key, entry))) + { + return; + } + + entry.InvokeEvictionCallbacks(); + } + + private void EntryExpired(CacheEntry entry) + { + RemoveEntry(entry); + StartScanForExpiredItems(); + } + + private void StartScanForExpiredItems() + { + var currentTime = _clock.UtcNow; + if (!(_expirationScanFrequency < currentTime - _lastExpirationScan)) + { + return; + } + + _lastExpirationScan = currentTime; + + Task.Factory.StartNew(state => ScanForExpiredItems((MemoryCache)state), this, CancellationToken.None, + TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + private static void ScanForExpiredItems(MemoryCache cache) + { + var currentTime = cache._clock.UtcNow; + foreach (CacheEntry entry in cache._cacheEntries.Values) + { + if (entry.IsExpired(currentTime)) + { + cache.RemoveEntry(entry); + } + } + } + + protected virtual void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + if (disposing) + { + GC.SuppressFinalize(this); + } + + _isDisposed = true; + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs b/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs new file mode 100644 index 0000000..739f15f --- /dev/null +++ b/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs @@ -0,0 +1,10 @@ +using HttpClient.Cache.InMemory.Clock; + +namespace HttpClient.Cache.InMemory; + +public class MemoryCacheOptions +{ + public TimeSpan ExpirationScanFrequency { get; } = TimeSpan.FromMinutes(1.0); + + public ISystemClock Clock { get; set; } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/PostEvictionCallbackRegistration.cs b/src/HttpClient.Cache/PostEvictionCallbackRegistration.cs index d03f387..7403c68 100644 --- a/src/HttpClient.Cache/PostEvictionCallbackRegistration.cs +++ b/src/HttpClient.Cache/PostEvictionCallbackRegistration.cs @@ -2,9 +2,10 @@ public class PostEvictionCallbackRegistration { - public PostEvictionDelegate EvictionCallback { get; set; } + //TODO: Replace with constructor - callback is not exists without state + public PostEvictionDelegate? EvictionCallback { get; set; } - public object State { get; set; } + public object? State { get; set; } } -public delegate void PostEvictionDelegate(object key, object value, string reason, object state); \ No newline at end of file +public delegate void PostEvictionDelegate(object key, object? value, string reason, object? state); \ No newline at end of file From b35eb4816a934cc754669205c1b22e49769d6bb6 Mon Sep 17 00:00:00 2001 From: Leefrost Date: Sat, 11 Mar 2023 23:24:53 +0200 Subject: [PATCH 03/13] Added inmemory cache handler --- .../DefaultCacheKeysProvider.cs | 14 ++++ src/HttpClient.Cache/ICacheKeysProvider.cs | 6 ++ .../InMemory/InMemoryCacheExtensions.cs | 43 ++++++++++ .../InMemory/InMemoryCacheHandler.cs | 83 +++++++++++++++++++ .../Utils/HttpResponseMessageExtensions.cs | 28 +++++++ .../Utils/HttpStatusCodeExtensions.cs | 23 +++++ .../HttpClient.Cache.Tests.csproj | 4 + 7 files changed, 201 insertions(+) create mode 100644 src/HttpClient.Cache/DefaultCacheKeysProvider.cs create mode 100644 src/HttpClient.Cache/ICacheKeysProvider.cs create mode 100644 src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs create mode 100644 src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs create mode 100644 src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs create mode 100644 src/HttpClient.Cache/Utils/HttpStatusCodeExtensions.cs diff --git a/src/HttpClient.Cache/DefaultCacheKeysProvider.cs b/src/HttpClient.Cache/DefaultCacheKeysProvider.cs new file mode 100644 index 0000000..4940f08 --- /dev/null +++ b/src/HttpClient.Cache/DefaultCacheKeysProvider.cs @@ -0,0 +1,14 @@ +namespace HttpClient.Cache; + +public class DefaultCacheKeysProvider : ICacheKeysProvider +{ + public string GetKey(HttpRequestMessage request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return $"MET_{request.Method};URI_{request.RequestUri}"; + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/ICacheKeysProvider.cs b/src/HttpClient.Cache/ICacheKeysProvider.cs new file mode 100644 index 0000000..317249d --- /dev/null +++ b/src/HttpClient.Cache/ICacheKeysProvider.cs @@ -0,0 +1,6 @@ +namespace HttpClient.Cache; + +public interface ICacheKeysProvider +{ + string GetKey(HttpRequestMessage request); +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs b/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs new file mode 100644 index 0000000..0949fd4 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs @@ -0,0 +1,43 @@ +using System.Diagnostics; + +namespace HttpClient.Cache.InMemory; + +public static class InMemoryCacheExtensions +{ + public static Task TryGetAsync(this IMemoryCache cache, string key) + { + try + { + if (cache.TryGetValue(key, out var data)) + { + var binaryData = (byte[])data; + return Task.FromResult(binaryData.Deserialize()); + } + + return Task.FromResult(default(CacheData)); + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(default(CacheData)); + } + } + + public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData value, + TimeSpan absoluteExpirationRelativeToNow) + { + try + { + var entry = cache.CreateEntry(key); + entry.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow; + entry.Value = value.Serialize(); + entry.Dispose(); + return Task.FromResult(true); + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(false); + } + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs new file mode 100644 index 0000000..8f4e962 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs @@ -0,0 +1,83 @@ +using System.Net; +using HttpClient.Cache.Stats; +using HttpClient.Cache.Utils; + +namespace HttpClient.Cache.InMemory; + +public class InMemoryCacheHandler : DelegatingHandler +{ + private readonly IMemoryCache _responseCache; + private readonly IDictionary _cacheExpirationPerHttpResponseCode; + + internal InMemoryCacheHandler( + HttpMessageHandler? innerHandler, + IDictionary? cacheExpirationPerHttpResponseCode, + ICacheStatsProvider? statsProvider, + IMemoryCache? responseCache, + ICacheKeysProvider cacheKeysProvider) + : base(innerHandler ?? new HttpClientHandler()) + { + StatsProvider = statsProvider ?? new DefaultCacheStatsProvider(nameof(InMemoryCacheHandler)); + CacheKeysProvider = cacheKeysProvider ?? new DefaultCacheKeysProvider(); + + _cacheExpirationPerHttpResponseCode = + cacheExpirationPerHttpResponseCode ?? new Dictionary(); + + _responseCache = responseCache ?? new MemoryCache(); + } + + public ICacheKeysProvider CacheKeysProvider { get; } + + public ICacheStatsProvider StatsProvider { get; } + + public void InvalidateCache(Uri uri, HttpMethod? method = null) + { + var methods = method != null + ? new[] { method } + : new[] { HttpMethod.Get, HttpMethod.Head, }; + + foreach (var actionMethod in methods) + { + var request = new HttpRequestMessage(actionMethod, uri); + var key = CacheKeysProvider.GetKey(request); + _responseCache.Remove(key); + } + } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + var key = CacheKeysProvider.GetKey(request); + if (request.Method == HttpMethod.Get || request.Method == HttpMethod.Head) + { + var cachedData = await _responseCache.TryGetAsync(key); + if (cachedData != null) + { + var cachedResponse = request.PrepareCacheEntry(cachedData); + StatsProvider.ReportHit((cachedResponse.StatusCode)); + + return cachedResponse; + } + } + + var response = await base.SendAsync(request, cancellationToken); + + if (request.Method == HttpMethod.Get || request.Method == HttpMethod.Head) + { + var absoluteExpirationRelativeToNow = + response.StatusCode.GetAbsoluteExpirationRelativeToNow(_cacheExpirationPerHttpResponseCode); + + StatsProvider.ReportMiss(response.StatusCode); + + if (TimeSpan.Zero != absoluteExpirationRelativeToNow) + { + var entry = await response.ToCacheEntry(); + await _responseCache.TrySetAsync(key, entry, absoluteExpirationRelativeToNow); + return request.PrepareCacheEntry(entry); + } + } + + return response; + } + +} \ No newline at end of file diff --git a/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs b/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..54cd33e --- /dev/null +++ b/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs @@ -0,0 +1,28 @@ +namespace HttpClient.Cache.Utils; + +public static class HttpResponseMessageExtensions +{ + public static async Task ToCacheEntry(this HttpResponseMessage response) + { + var data = await response.Content.ReadAsByteArrayAsync(); + var copy = new HttpResponseMessage + { + ReasonPhrase = response.ReasonPhrase, StatusCode = response.StatusCode, Version = response.Version + }; + + //TODO: headers are important. Will be added later; + + var entry = new CacheData(data, copy); + return entry; + } + + public static HttpResponseMessage PrepareCacheEntry(this HttpRequestMessage request, CacheData cacheData) + { + //TODO: headers + var response = cacheData.Response; + response.Content = new ByteArrayContent(cacheData.Data); + response.RequestMessage = request; + + return response; + } +} \ No newline at end of file diff --git a/src/HttpClient.Cache/Utils/HttpStatusCodeExtensions.cs b/src/HttpClient.Cache/Utils/HttpStatusCodeExtensions.cs new file mode 100644 index 0000000..5ea3820 --- /dev/null +++ b/src/HttpClient.Cache/Utils/HttpStatusCodeExtensions.cs @@ -0,0 +1,23 @@ +using System.Net; + +namespace HttpClient.Cache.Utils; + +public static class HttpStatusCodeExtensions +{ + public static TimeSpan GetAbsoluteExpirationRelativeToNow(this HttpStatusCode statusCode, + IDictionary mapping) + { + if (mapping.TryGetValue(statusCode, out var expiration)) + { + return expiration; + } + + var code = (int)statusCode; + if (mapping.TryGetValue((HttpStatusCode)(Math.Floor(code / 100.0) * 100), out expiration)) + { + return expiration; + } + + return TimeSpan.FromDays(1); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj b/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj index 2974db9..809580b 100644 --- a/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj +++ b/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj @@ -30,4 +30,8 @@ + + + + From d0c6b872a3ce0a26d0941d935daf6794ae1f3e6f Mon Sep 17 00:00:00 2001 From: Leefrost Date: Sun, 12 Mar 2023 22:47:27 +0200 Subject: [PATCH 04/13] Changes: - fixed Json serialization problem - added base tests for handler - fixed problem around expiration --- src/HttpClient.Cache/CacheDataExtensions.cs | 11 ++-- src/HttpClient.Cache/HttpClient.Cache.csproj | 4 ++ src/HttpClient.Cache/InMemory/CacheEntry.cs | 6 ++- .../InMemory/InMemoryCacheExtensions.cs | 3 +- .../InMemory/InMemoryCacheHandler.cs | 14 ++++- src/HttpClient.Cache/InMemory/MemoryCache.cs | 9 ++-- .../InMemory/MemoryCacheOptions.cs | 2 +- .../HttpClient.Cache.Tests.csproj | 4 -- .../InMemory/InMemoryCacheHandlerTests.cs | 19 +++++++ tests/HttpClient.Cache.Tests/TestHandler.cs | 51 +++++++++++++++++++ 10 files changed, 104 insertions(+), 19 deletions(-) create mode 100644 tests/HttpClient.Cache.Tests/InMemory/InMemoryCacheHandlerTests.cs create mode 100644 tests/HttpClient.Cache.Tests/TestHandler.cs diff --git a/src/HttpClient.Cache/CacheDataExtensions.cs b/src/HttpClient.Cache/CacheDataExtensions.cs index 75b888d..f7e83dd 100644 --- a/src/HttpClient.Cache/CacheDataExtensions.cs +++ b/src/HttpClient.Cache/CacheDataExtensions.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using Newtonsoft.Json; namespace HttpClient.Cache; @@ -6,7 +6,7 @@ public static class CacheDataExtensions { public static byte[] Serialize(this CacheData cacheData) { - string json = JsonSerializer.Serialize(cacheData); + string json = JsonConvert.SerializeObject(cacheData); byte[] bytes = new byte[json.Length * sizeof(char)]; Buffer.BlockCopy(json.ToCharArray(), 0, bytes, 0, bytes.Length); @@ -19,11 +19,12 @@ public static byte[] Serialize(this CacheData cacheData) { char[] chars = new char[cacheData.Length / sizeof(char)]; Buffer.BlockCopy(cacheData, 0, chars, 0, cacheData.Length); - string json = new string(chars); - CacheData? data = JsonSerializer.Deserialize(json); + string json = new(chars); + + var data = JsonConvert.DeserializeObject(json); return data; } - catch + catch (Exception ex) { return null; } diff --git a/src/HttpClient.Cache/HttpClient.Cache.csproj b/src/HttpClient.Cache/HttpClient.Cache.csproj index eb2460e..5022b2d 100644 --- a/src/HttpClient.Cache/HttpClient.Cache.csproj +++ b/src/HttpClient.Cache/HttpClient.Cache.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/HttpClient.Cache/InMemory/CacheEntry.cs b/src/HttpClient.Cache/InMemory/CacheEntry.cs index 8b14ad8..443ef7a 100644 --- a/src/HttpClient.Cache/InMemory/CacheEntry.cs +++ b/src/HttpClient.Cache/InMemory/CacheEntry.cs @@ -20,7 +20,9 @@ internal class CacheEntry : ICacheEntry private bool _isDisposed; private bool _isExpired; - internal CacheEntry(object key, Action notifyCacheEntryDisposed, + internal CacheEntry( + object key, + Action notifyCacheEntryDisposed, Action notifyCacheOfExpiration) { Key = key ?? throw new ArgumentNullException(nameof(key)); @@ -100,7 +102,7 @@ public void Dispose() internal bool IsExpired(DateTimeOffset currentTime) { - if (_isExpired && !IsExpiredNow(currentTime)) + if (!_isExpired && !IsExpiredNow(currentTime)) { return IsAnyExpirationTokenExpired(); } diff --git a/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs b/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs index 0949fd4..df8a203 100644 --- a/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs +++ b/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs @@ -4,7 +4,7 @@ namespace HttpClient.Cache.InMemory; public static class InMemoryCacheExtensions { - public static Task TryGetAsync(this IMemoryCache cache, string key) + public static Task TryGetAsync(this IMemoryCache cache, string key) { try { @@ -32,6 +32,7 @@ public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData va entry.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow; entry.Value = value.Serialize(); entry.Dispose(); + return Task.FromResult(true); } catch (Exception ex) diff --git a/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs index 8f4e962..2ab1ecc 100644 --- a/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs +++ b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs @@ -8,13 +8,25 @@ public class InMemoryCacheHandler : DelegatingHandler { private readonly IMemoryCache _responseCache; private readonly IDictionary _cacheExpirationPerHttpResponseCode; + + public InMemoryCacheHandler( + HttpMessageHandler? innerHandler, + IDictionary? cacheExpirationPerHttpResponseCode = null, + ICacheStatsProvider? statsProvider = null, + ICacheKeysProvider? cacheKeysProvider = null) + :this(innerHandler, + cacheExpirationPerHttpResponseCode, + statsProvider, + new MemoryCache(new MemoryCacheOptions()), + cacheKeysProvider) + { } internal InMemoryCacheHandler( HttpMessageHandler? innerHandler, IDictionary? cacheExpirationPerHttpResponseCode, ICacheStatsProvider? statsProvider, IMemoryCache? responseCache, - ICacheKeysProvider cacheKeysProvider) + ICacheKeysProvider? cacheKeysProvider) : base(innerHandler ?? new HttpClientHandler()) { StatsProvider = statsProvider ?? new DefaultCacheStatsProvider(nameof(InMemoryCacheHandler)); diff --git a/src/HttpClient.Cache/InMemory/MemoryCache.cs b/src/HttpClient.Cache/InMemory/MemoryCache.cs index 28e9b25..84901f4 100644 --- a/src/HttpClient.Cache/InMemory/MemoryCache.cs +++ b/src/HttpClient.Cache/InMemory/MemoryCache.cs @@ -33,7 +33,7 @@ public MemoryCache(MemoryCacheOptions options) _setEntry = SetEntry; _entryExpirationNotification = EntryExpired; - _clock = options.Clock; + _clock = options.Clock ?? new SystemClock(); _expirationScanFrequency = options.ExpirationScanFrequency; _lastExpirationScan = _clock.UtcNow; } @@ -140,13 +140,12 @@ private void SetEntry(CacheEntry entry) return; } - DateTimeOffset currentTime = _clock.UtcNow; - DateTimeOffset? entryAbsoluteExpiration = new(); + var currentTime = _clock.UtcNow; + var entryAbsoluteExpiration = new DateTimeOffset?(); if (entry.AbsoluteExpirationRelativeToNow.HasValue) { - TimeSpan? expirationRelativeToNow = entry.AbsoluteExpirationRelativeToNow; - entryAbsoluteExpiration = currentTime + expirationRelativeToNow; + entryAbsoluteExpiration = currentTime + entry.AbsoluteExpirationRelativeToNow; } else if (entry.AbsoluteExpiration.HasValue) { diff --git a/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs b/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs index 739f15f..bd61cbc 100644 --- a/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs +++ b/src/HttpClient.Cache/InMemory/MemoryCacheOptions.cs @@ -6,5 +6,5 @@ public class MemoryCacheOptions { public TimeSpan ExpirationScanFrequency { get; } = TimeSpan.FromMinutes(1.0); - public ISystemClock Clock { get; set; } + public ISystemClock? Clock { get; set; } } \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj b/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj index 809580b..2974db9 100644 --- a/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj +++ b/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj @@ -30,8 +30,4 @@ - - - - diff --git a/tests/HttpClient.Cache.Tests/InMemory/InMemoryCacheHandlerTests.cs b/tests/HttpClient.Cache.Tests/InMemory/InMemoryCacheHandlerTests.cs new file mode 100644 index 0000000..8cc86d3 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/InMemory/InMemoryCacheHandlerTests.cs @@ -0,0 +1,19 @@ +using FluentAssertions; +using HttpClient.Cache.InMemory; + +namespace HttpClient.Cache.Tests.InMemory; + +public class InMemoryCacheHandlerTests +{ + [Fact] + public async Task Cache_HandleNewCacheMessage_Successful() + { + var testHandler = new TestHandler(); + var client = new System.Net.Http.HttpClient(new InMemoryCacheHandler(testHandler)); + + await client.GetAsync("http://the-url"); + await client.GetAsync("http://the-url"); + + testHandler.CallsMade.Should().Be(1); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/TestHandler.cs b/tests/HttpClient.Cache.Tests/TestHandler.cs new file mode 100644 index 0000000..61d8224 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/TestHandler.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Text; + +namespace HttpClient.Cache.Tests; + +public class TestHandler : HttpMessageHandler +{ + internal const HttpStatusCode DefaultCode = HttpStatusCode.OK; + internal const string DefaultContent = "The response content"; + internal const string DefaultContentType = "text/plain"; + + private readonly string _content; + private readonly string _contentType; + private readonly TimeSpan _delay; + private readonly Encoding? _encoding; + + private readonly HttpStatusCode _responseCode; + + public TestHandler( + HttpStatusCode responseCode = DefaultCode, + string content = DefaultContent, + string contentType = DefaultContentType, + Encoding? encoding = null, + TimeSpan delay = default + ) + { + _responseCode = responseCode; + _content = content; + _contentType = contentType; + _encoding = encoding ?? Encoding.UTF8; + _delay = delay; + } + + public int CallsMade { get; set; } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + CallsMade++; + if (_delay != default) + { + await Task.Delay(_delay, cancellationToken); + } + + return new HttpResponseMessage + { + Content = new StringContent(_content, _encoding, _contentType), + StatusCode = _responseCode + }; + } +} \ No newline at end of file From d19b90871a9e65687c2e49dab790c25090ec20a2 Mon Sep 17 00:00:00 2001 From: Leefrost Date: Mon, 13 Mar 2023 21:17:27 +0200 Subject: [PATCH 05/13] Added tests for CacheDataExtensions.cs --- src/HttpClient.Cache/CacheDataExtensions.cs | 16 +++--- .../InMemory/InMemoryCacheExtensions.cs | 12 ++--- .../Utils/HttpResponseMessageExtensions.cs | 3 +- .../CacheDataExtensionsTests.cs | 49 +++++++++++++++++++ .../HttpClient.Cache.Tests.csproj | 1 + 5 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 tests/HttpClient.Cache.Tests/CacheDataExtensionsTests.cs diff --git a/src/HttpClient.Cache/CacheDataExtensions.cs b/src/HttpClient.Cache/CacheDataExtensions.cs index f7e83dd..cce68fe 100644 --- a/src/HttpClient.Cache/CacheDataExtensions.cs +++ b/src/HttpClient.Cache/CacheDataExtensions.cs @@ -1,31 +1,33 @@ -using Newtonsoft.Json; +using System.Diagnostics; +using Newtonsoft.Json; namespace HttpClient.Cache; public static class CacheDataExtensions { - public static byte[] Serialize(this CacheData cacheData) + public static byte[] Pack(this CacheData cacheData) { - string json = JsonConvert.SerializeObject(cacheData); - byte[] bytes = new byte[json.Length * sizeof(char)]; + var json = JsonConvert.SerializeObject(cacheData); + var bytes = new byte[json.Length * sizeof(char)]; Buffer.BlockCopy(json.ToCharArray(), 0, bytes, 0, bytes.Length); return bytes; } - public static CacheData? Deserialize(this byte[] cacheData) + public static CacheData? Unpack(this byte[] cacheData) { try { - char[] chars = new char[cacheData.Length / sizeof(char)]; + var chars = new char[cacheData.Length / sizeof(char)]; Buffer.BlockCopy(cacheData, 0, chars, 0, cacheData.Length); - string json = new(chars); + var json = new string(chars); var data = JsonConvert.DeserializeObject(json); return data; } catch (Exception ex) { + Debug.WriteLine($"{ex}"); return null; } } diff --git a/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs b/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs index df8a203..f4fae6e 100644 --- a/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs +++ b/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs @@ -11,7 +11,7 @@ public static class InMemoryCacheExtensions if (cache.TryGetValue(key, out var data)) { var binaryData = (byte[])data; - return Task.FromResult(binaryData.Deserialize()); + return Task.FromResult(binaryData.Unpack()); } return Task.FromResult(default(CacheData)); @@ -28,11 +28,11 @@ public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData va { try { - var entry = cache.CreateEntry(key); - entry.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow; - entry.Value = value.Serialize(); - entry.Dispose(); - + using (var entry = cache.CreateEntry(key)) + { + entry.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow; + entry.Value = value.Pack(); + } return Task.FromResult(true); } catch (Exception ex) diff --git a/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs b/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs index 54cd33e..d81658e 100644 --- a/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs +++ b/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs @@ -18,11 +18,12 @@ public static async Task ToCacheEntry(this HttpResponseMessage respon public static HttpResponseMessage PrepareCacheEntry(this HttpRequestMessage request, CacheData cacheData) { - //TODO: headers var response = cacheData.Response; response.Content = new ByteArrayContent(cacheData.Data); response.RequestMessage = request; + //TODO: headers are important. Will be added later; + return response; } } \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/CacheDataExtensionsTests.cs b/tests/HttpClient.Cache.Tests/CacheDataExtensionsTests.cs new file mode 100644 index 0000000..1dd31a2 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/CacheDataExtensionsTests.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using FluentAssertions.Execution; +using Newtonsoft.Json; + +namespace HttpClient.Cache.Tests; + +public class CacheDataExtensionsTests +{ + [Fact] + public void Pack_CreateABytesFromCacheData_ReturnByteArray() + { + var message = Encoding.UTF8.GetBytes("Here is a message"); + var response = + new HttpResponseMessage { Content = new ByteArrayContent(message), StatusCode = HttpStatusCode.OK }; + var data = new CacheData(message, response); + + var packData = data.Pack(); + + using (new AssertionScope()) + { + var chars = new char[packData.Length / sizeof(char)]; + Buffer.BlockCopy(packData, 0, chars, 0, packData.Length); + var json = new string(chars); + + var unpackedData = JsonConvert.DeserializeObject(json); + + packData.Should().NotBeNullOrEmpty(); + unpackedData.Should().BeEquivalentTo(data); + } + } + + [Fact] + public void UnPack_CreateCacheDataEn_ReturnByteArray() + { + var message = Encoding.UTF8.GetBytes("Here is a message"); + var response = + new HttpResponseMessage { Content = new ByteArrayContent(message), StatusCode = HttpStatusCode.OK }; + var cacheData = new CacheData(message, response); + var serializeObject = JsonConvert.SerializeObject(cacheData); + var bytes = new byte[serializeObject.Length * sizeof(char)]; + Buffer.BlockCopy(serializeObject.ToCharArray(), 0, bytes, 0, bytes.Length); + + var unpackData = bytes.Unpack(); + + unpackData.Should().NotBeNull().And.BeEquivalentTo(cacheData); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj b/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj index 2974db9..f8fee8d 100644 --- a/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj +++ b/tests/HttpClient.Cache.Tests/HttpClient.Cache.Tests.csproj @@ -15,6 +15,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + runtime; build; native; contentfiles; analyzers; buildtransitive From f5e973b5861a1c55e9015aa25e9543931e1a67d5 Mon Sep 17 00:00:00 2001 From: Leefrost Date: Tue, 14 Mar 2023 20:52:27 +0200 Subject: [PATCH 06/13] Added tests for MemoryCacheExtensions --- ...eItemPriority.cs => CacheEntryPriority.cs} | 2 +- src/HttpClient.Cache/ICacheEntry.cs | 2 +- src/HttpClient.Cache/InMemory/CacheEntry.cs | 2 +- .../InMemory/InMemoryCacheExtensions.cs | 44 --------- .../InMemory/InMemoryCacheHandler.cs | 7 +- .../InMemory/MemoryCacheExtensions.cs | 95 +++++++++++++++++++ .../InMemory/MemoryCacheExtensionsTests.cs | 78 +++++++++++++++ 7 files changed, 179 insertions(+), 51 deletions(-) rename src/HttpClient.Cache/{CacheItemPriority.cs => CacheEntryPriority.cs} (72%) delete mode 100644 src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs create mode 100644 src/HttpClient.Cache/InMemory/MemoryCacheExtensions.cs create mode 100644 tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs diff --git a/src/HttpClient.Cache/CacheItemPriority.cs b/src/HttpClient.Cache/CacheEntryPriority.cs similarity index 72% rename from src/HttpClient.Cache/CacheItemPriority.cs rename to src/HttpClient.Cache/CacheEntryPriority.cs index add89ff..2b4c8b2 100644 --- a/src/HttpClient.Cache/CacheItemPriority.cs +++ b/src/HttpClient.Cache/CacheEntryPriority.cs @@ -1,6 +1,6 @@ namespace HttpClient.Cache; -public enum CacheItemPriority +public enum CacheEntryPriority { Low, Normal, diff --git a/src/HttpClient.Cache/ICacheEntry.cs b/src/HttpClient.Cache/ICacheEntry.cs index 454ff14..b447493 100644 --- a/src/HttpClient.Cache/ICacheEntry.cs +++ b/src/HttpClient.Cache/ICacheEntry.cs @@ -14,5 +14,5 @@ public interface ICacheEntry: IDisposable IList PostEvictionCallbacks { get; } - CacheItemPriority Priority { get; set; } + CacheEntryPriority Priority { get; set; } } \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/CacheEntry.cs b/src/HttpClient.Cache/InMemory/CacheEntry.cs index 443ef7a..1018a7c 100644 --- a/src/HttpClient.Cache/InMemory/CacheEntry.cs +++ b/src/HttpClient.Cache/InMemory/CacheEntry.cs @@ -36,7 +36,7 @@ internal CacheEntry( public object Key { get; } public object Value { get; set; } - public CacheItemPriority Priority { get; set; } = CacheItemPriority.Normal; + public CacheEntryPriority Priority { get; set; } = CacheEntryPriority.Normal; public DateTimeOffset? AbsoluteExpiration { diff --git a/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs b/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs deleted file mode 100644 index f4fae6e..0000000 --- a/src/HttpClient.Cache/InMemory/InMemoryCacheExtensions.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Diagnostics; - -namespace HttpClient.Cache.InMemory; - -public static class InMemoryCacheExtensions -{ - public static Task TryGetAsync(this IMemoryCache cache, string key) - { - try - { - if (cache.TryGetValue(key, out var data)) - { - var binaryData = (byte[])data; - return Task.FromResult(binaryData.Unpack()); - } - - return Task.FromResult(default(CacheData)); - } - catch (Exception ex) - { - Debug.WriteLine($"{ex}"); - return Task.FromResult(default(CacheData)); - } - } - - public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData value, - TimeSpan absoluteExpirationRelativeToNow) - { - try - { - using (var entry = cache.CreateEntry(key)) - { - entry.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow; - entry.Value = value.Pack(); - } - return Task.FromResult(true); - } - catch (Exception ex) - { - Debug.WriteLine($"{ex}"); - return Task.FromResult(false); - } - } -} \ No newline at end of file diff --git a/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs index 2ab1ecc..f20091e 100644 --- a/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs +++ b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs @@ -62,14 +62,13 @@ protected override async Task SendAsync(HttpRequestMessage var key = CacheKeysProvider.GetKey(request); if (request.Method == HttpMethod.Get || request.Method == HttpMethod.Head) { - var cachedData = await _responseCache.TryGetAsync(key); - if (cachedData != null) + if (await _responseCache.TryGetAsync(key, out var cachedData) && cachedData != default) { var cachedResponse = request.PrepareCacheEntry(cachedData); - StatsProvider.ReportHit((cachedResponse.StatusCode)); + StatsProvider.ReportHit(cachedResponse.StatusCode); return cachedResponse; - } + }; } var response = await base.SendAsync(request, cancellationToken); diff --git a/src/HttpClient.Cache/InMemory/MemoryCacheExtensions.cs b/src/HttpClient.Cache/InMemory/MemoryCacheExtensions.cs new file mode 100644 index 0000000..49a8f32 --- /dev/null +++ b/src/HttpClient.Cache/InMemory/MemoryCacheExtensions.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; + +namespace HttpClient.Cache.InMemory; + +public static class MemoryCacheExtensions +{ + public static Task TryGetAsync(this IMemoryCache cache, string key, out CacheData? cacheData) + { + cacheData = default; + try + { + if (!cache.TryGetValue(key, out var data)) + { + return Task.FromResult(false); + } + + if (data is null) + { + return Task.FromResult(false); + } + + var binaryData = (byte[])data; + cacheData = binaryData.Unpack(); + + return Task.FromResult(true); + + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(false); + } + } + + public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData cacheData, + TimeSpan absoluteExpirationRelativeToNow) + { + try + { + using (var entry = cache.CreateEntry(key)) + { + entry.AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow; + entry.Value = cacheData.Pack(); + } + + return Task.FromResult(true); + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(false); + } + } + + public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData cacheData, + DateTimeOffset absoluteExpiration) + { + try + { + using (var entry = cache.CreateEntry(key)) + { + entry.AbsoluteExpiration = absoluteExpiration; + entry.Value = cacheData.Pack(); + } + + return Task.FromResult(true); + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(false); + } + } + + public static Task TrySetAsync(this IMemoryCache cache, string key, CacheData cacheData, CacheEntryPriority priority, + TimeSpan? slidingExpiration) + { + try + { + using (var entry = cache.CreateEntry(key)) + { + entry.Priority = priority; + entry.SlidingExpiration = slidingExpiration; + entry.Value = cacheData.Pack(); + } + + return Task.FromResult(true); + } + catch (Exception ex) + { + Debug.WriteLine($"{ex}"); + return Task.FromResult(false); + } + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs new file mode 100644 index 0000000..4bdebe2 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using FluentAssertions.Execution; +using HttpClient.Cache.InMemory; + +namespace HttpClient.Cache.Tests.InMemory; + +public class MemoryCacheExtensionsTests +{ + + [Fact] + public async Task TryGetAsync_GetCacheItemFromMemoryCacheOut_ReturnTrue() + { + const string cacheKey = "key"; + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheData = new CacheData(Encoding.UTF8.GetBytes("message"), + new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + + using (var entry = memoryCache.CreateEntry(cacheKey)) + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); + entry.Value = cacheData.Pack(); + } + + var result = await memoryCache.TryGetAsync(cacheKey, out var packedData); + + using (new AssertionScope()) + { + result.Should().BeTrue(); + packedData.Should().BeEquivalentTo(cacheData); + } + } + + [Fact] + public async Task TrySetAsync_SetCacheDataByKeyAndExpRelativeToNow_ReturnTrue() + { + const string cacheKey = "key"; + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheData = new CacheData(Encoding.UTF8.GetBytes("message"), + new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + var absoluteTimeoutRelativeToNow = TimeSpan.FromDays(1); + + var result = await memoryCache.TrySetAsync(cacheKey, cacheData, absoluteTimeoutRelativeToNow); + + result.Should().BeTrue(); + } + + [Fact] + public async Task TrySetAsync_SetCacheDataByKeyAndAbsoluteExp_ReturnTrue() + { + const string cacheKey = "key"; + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheData = new CacheData(Encoding.UTF8.GetBytes("message"), + new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + var absoluteExpiration = DateTimeOffset.UtcNow.AddDays(1); + + var result = await memoryCache.TrySetAsync(cacheKey, cacheData, absoluteExpiration); + + result.Should().BeTrue(); + } + + [Fact] + public async Task TrySetAsync_SetCacheDataByKeySlidingWindow_ReturnTrue() + { + const string cacheKey = "key"; + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var cacheData = new CacheData(Encoding.UTF8.GetBytes("message"), + new HttpResponseMessage { StatusCode = HttpStatusCode.OK }); + var slidingExpiration = TimeSpan.FromDays(1); + + var result = await memoryCache.TrySetAsync(cacheKey, cacheData, CacheEntryPriority.Normal, slidingExpiration); + + result.Should().BeTrue(); + } + + +} \ No newline at end of file From 70f909a4f9dd6be2f7ed1e4063d3f453784ee2a9 Mon Sep 17 00:00:00 2001 From: Leefrost Date: Tue, 14 Mar 2023 22:01:58 +0200 Subject: [PATCH 07/13] Added base tests to memory cache --- src/HttpClient.Cache/InMemory/MemoryCache.cs | 6 +- .../InMemory/MemoryCacheExtensionsTests.cs | 3 - .../InMemory/MemoryCacheTests.cs | 105 ++++++++++++++++++ tests/HttpClient.Cache.Tests/TestClock.cs | 8 ++ 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs create mode 100644 tests/HttpClient.Cache.Tests/TestClock.cs diff --git a/src/HttpClient.Cache/InMemory/MemoryCache.cs b/src/HttpClient.Cache/InMemory/MemoryCache.cs index 84901f4..137b9f0 100644 --- a/src/HttpClient.Cache/InMemory/MemoryCache.cs +++ b/src/HttpClient.Cache/InMemory/MemoryCache.cs @@ -3,7 +3,7 @@ namespace HttpClient.Cache.InMemory; -public class MemoryCache : IMemoryCache +public sealed class MemoryCache : IMemoryCache { private readonly ISystemClock _clock; private readonly ConcurrentDictionary _cacheEntries; @@ -42,6 +42,8 @@ public MemoryCache(MemoryCacheOptions options) { Dispose(false); } + + public int Count => _cacheEntries.Count; public ICacheEntry CreateEntry(object key) { @@ -247,7 +249,7 @@ private static void ScanForExpiredItems(MemoryCache cache) } } - protected virtual void Dispose(bool disposing) + private void Dispose(bool disposing) { if (_isDisposed) { diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs index 4bdebe2..a00c503 100644 --- a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheExtensionsTests.cs @@ -8,7 +8,6 @@ namespace HttpClient.Cache.Tests.InMemory; public class MemoryCacheExtensionsTests { - [Fact] public async Task TryGetAsync_GetCacheItemFromMemoryCacheOut_ReturnTrue() { @@ -73,6 +72,4 @@ public async Task TrySetAsync_SetCacheDataByKeySlidingWindow_ReturnTrue() result.Should().BeTrue(); } - - } \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs new file mode 100644 index 0000000..456044e --- /dev/null +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs @@ -0,0 +1,105 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using HttpClient.Cache.InMemory; +using HttpClient.Cache.InMemory.Clock; + +namespace HttpClient.Cache.Tests.InMemory; + +public class MemoryCacheTests +{ + [Fact] + public void CreateEntry_Create10NewEntryAndSetValue_CacheContains10Items() + { + var expiration = TimeSpan.FromHours(1); + var options = new MemoryCacheOptions(); + var cache = new MemoryCache(options); + + for (var i = 1; i <= 10; ++i) + { + using var entry = cache.CreateEntry($"{i}"); + entry.AbsoluteExpirationRelativeToNow = expiration; + entry.Value = $"value-{i}"; + } + + using (new AssertionScope()) + { + cache.Count.Should().Be(10); + + var val = cache.TryGetValue("9", out var cachedItem); + val.Should().BeTrue(); + cachedItem.Should().Be("value-9"); + } + } + + [Fact] + public async Task CreateEntry_ExpireAbsoluteExpirationRelativeToNow_CacheIsEmpty() + { + var expiration = TimeSpan.FromSeconds(2); + var options = new MemoryCacheOptions(); + var cache = new MemoryCache(options); + + using(var entry = cache.CreateEntry("key")){ + entry.AbsoluteExpirationRelativeToNow = expiration; + entry.Value = $"value"; + } + + using (new AssertionScope()) + { + cache.Count.Should().Be(1); + await Task.Delay(expiration); + + var value = cache.TryGetValue("key", out var cacheEntry); + value.Should().BeFalse(); + cacheEntry.Should().BeNull(); + cache.Count.Should().Be(0); + } + } + + [Fact] + public async Task CreateEntry_ExpireSlidingExpiration_CacheIsEmpty() + { + var expiration = TimeSpan.FromSeconds(2); + var options = new MemoryCacheOptions(); + var cache = new MemoryCache(options); + + using(var entry = cache.CreateEntry("key")){ + entry.SlidingExpiration = expiration; + entry.Value = $"value"; + } + + using (new AssertionScope()) + { + cache.Count.Should().Be(1); + await Task.Delay(expiration); + + var value = cache.TryGetValue("key", out var cacheEntry); + value.Should().BeFalse(); + cacheEntry.Should().BeNull(); + cache.Count.Should().Be(0); + } + } + + [Fact] + public async Task CreateEntry_ExpireAbsoluteDate_CacheIsEmpty() + { + var expiration = DateTimeOffset.UtcNow.AddSeconds(2); + var options = new MemoryCacheOptions(); + var cache = new MemoryCache(options); + + using(var entry = cache.CreateEntry("key")){ + entry.AbsoluteExpiration = expiration; + entry.Value = $"value"; + } + + using (new AssertionScope()) + { + cache.Count.Should().Be(1); + await Task.Delay(TimeSpan.FromSeconds(2)); + + var value = cache.TryGetValue("key", out var cacheEntry); + value.Should().BeFalse(); + cacheEntry.Should().BeNull(); + cache.Count.Should().Be(0); + } + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/TestClock.cs b/tests/HttpClient.Cache.Tests/TestClock.cs new file mode 100644 index 0000000..ff50b74 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/TestClock.cs @@ -0,0 +1,8 @@ +using HttpClient.Cache.InMemory.Clock; + +namespace HttpClient.Cache.Tests; + +public class TestClock : ISystemClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow + TimeSpan.FromDays(1); +} \ No newline at end of file From ad5043edd461584ebca8fbbb0e32aa4418f68165 Mon Sep 17 00:00:00 2001 From: Leefrost Date: Wed, 15 Mar 2023 21:35:02 +0200 Subject: [PATCH 08/13] Added tests for DefaultCacheKeysProvider --- .../DefaultCacheKeysProvider.cs | 2 +- .../DefaultCacheKeysProviderTests.cs | 28 +++++++++++++++++++ .../InMemory/MemoryCacheTests.cs | 1 - 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 tests/HttpClient.Cache.Tests/DefaultCacheKeysProviderTests.cs diff --git a/src/HttpClient.Cache/DefaultCacheKeysProvider.cs b/src/HttpClient.Cache/DefaultCacheKeysProvider.cs index 4940f08..3e86dc9 100644 --- a/src/HttpClient.Cache/DefaultCacheKeysProvider.cs +++ b/src/HttpClient.Cache/DefaultCacheKeysProvider.cs @@ -2,7 +2,7 @@ public class DefaultCacheKeysProvider : ICacheKeysProvider { - public string GetKey(HttpRequestMessage request) + public string GetKey(HttpRequestMessage? request) { if (request is null) { diff --git a/tests/HttpClient.Cache.Tests/DefaultCacheKeysProviderTests.cs b/tests/HttpClient.Cache.Tests/DefaultCacheKeysProviderTests.cs new file mode 100644 index 0000000..72eb1ed --- /dev/null +++ b/tests/HttpClient.Cache.Tests/DefaultCacheKeysProviderTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; + +namespace HttpClient.Cache.Tests; + +public class DefaultCacheKeysProviderTests +{ + [Fact] + public void GetKey_GetKeyForMessage_ReturnKey() + { + var request = new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = new Uri("http://testurl") }; + var cacheProvider = new DefaultCacheKeysProvider(); + + var cacheKey = cacheProvider.GetKey(request); + + cacheKey.Should().Be($"MET_{request.Method};URI_{request.RequestUri}"); + } + + [Fact] + public void GetKey_RequestIsNull_ThrowException() + { + var request = new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = new Uri("http://testurl") }; + var cacheProvider = new DefaultCacheKeysProvider(); + + var action = () => cacheProvider.GetKey(null); + + action.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs index 456044e..3469eb3 100644 --- a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs @@ -1,7 +1,6 @@ using FluentAssertions; using FluentAssertions.Execution; using HttpClient.Cache.InMemory; -using HttpClient.Cache.InMemory.Clock; namespace HttpClient.Cache.Tests.InMemory; From aa111b7f15aa1b02bf2b9a632caf6e47280fce5a Mon Sep 17 00:00:00 2001 From: Leefrost Date: Wed, 15 Mar 2023 21:39:03 +0200 Subject: [PATCH 09/13] Fixed broken test --- tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs index 3469eb3..c94b980 100644 --- a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs @@ -33,7 +33,7 @@ public void CreateEntry_Create10NewEntryAndSetValue_CacheContains10Items() [Fact] public async Task CreateEntry_ExpireAbsoluteExpirationRelativeToNow_CacheIsEmpty() { - var expiration = TimeSpan.FromSeconds(2); + var expiration = TimeSpan.FromSeconds(3); var options = new MemoryCacheOptions(); var cache = new MemoryCache(options); From fe49842ef2df156275c1b01f26a462025bf0bad0 Mon Sep 17 00:00:00 2001 From: Leefrost Date: Wed, 15 Mar 2023 21:50:37 +0200 Subject: [PATCH 10/13] Disable parallelization for time-sensitive tests --- tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs index c94b980..505d649 100644 --- a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs @@ -4,6 +4,7 @@ namespace HttpClient.Cache.Tests.InMemory; +[CollectionDefinition("Sequential", DisableParallelization = true)] public class MemoryCacheTests { [Fact] From 0efdbc1fa50f91556a32283ce14955faec863ae6 Mon Sep 17 00:00:00 2001 From: Leefrost Date: Wed, 15 Mar 2023 22:17:34 +0200 Subject: [PATCH 11/13] Align base tests for memory cache --- tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs index 505d649..d8d83a6 100644 --- a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs @@ -64,6 +64,7 @@ public async Task CreateEntry_ExpireSlidingExpiration_CacheIsEmpty() using(var entry = cache.CreateEntry("key")){ entry.SlidingExpiration = expiration; + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddSeconds(1); entry.Value = $"value"; } @@ -82,19 +83,18 @@ public async Task CreateEntry_ExpireSlidingExpiration_CacheIsEmpty() [Fact] public async Task CreateEntry_ExpireAbsoluteDate_CacheIsEmpty() { - var expiration = DateTimeOffset.UtcNow.AddSeconds(2); var options = new MemoryCacheOptions(); var cache = new MemoryCache(options); using(var entry = cache.CreateEntry("key")){ - entry.AbsoluteExpiration = expiration; + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddSeconds(2); entry.Value = $"value"; } using (new AssertionScope()) { cache.Count.Should().Be(1); - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(3)); var value = cache.TryGetValue("key", out var cacheEntry); value.Should().BeFalse(); From 0f6a0b890be19db0df0d81645cc9623d729a856c Mon Sep 17 00:00:00 2001 From: Leefrost Date: Wed, 15 Mar 2023 22:37:08 +0200 Subject: [PATCH 12/13] Update relative expiration tests --- src/HttpClient.Cache/InMemory/CacheEntry.cs | 10 +++++----- .../InMemory/MemoryCacheTests.cs | 5 ++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/HttpClient.Cache/InMemory/CacheEntry.cs b/src/HttpClient.Cache/InMemory/CacheEntry.cs index 1018a7c..2678d45 100644 --- a/src/HttpClient.Cache/InMemory/CacheEntry.cs +++ b/src/HttpClient.Cache/InMemory/CacheEntry.cs @@ -124,13 +124,13 @@ private bool IsExpiredNow(DateTimeOffset currentTime) var accessedOffset = currentTime - LastAccessed; var expiration = _slidingExpiration; - if ((expiration.HasValue ? accessedOffset >= expiration.GetValueOrDefault() ? 1 : 0 : 0) == 0) + if ((expiration.HasValue ? (accessedOffset >= expiration.GetValueOrDefault() ? 1 : 0) : 0) != 0) { - return false; + ExpireEntryByReason(EvictionReason.Expired); + return true; } - - ExpireEntryByReason(EvictionReason.Expired); - return true; + + return false; } private bool IsAnyExpirationTokenExpired() diff --git a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs index d8d83a6..b028e17 100644 --- a/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs +++ b/tests/HttpClient.Cache.Tests/InMemory/MemoryCacheTests.cs @@ -34,19 +34,18 @@ public void CreateEntry_Create10NewEntryAndSetValue_CacheContains10Items() [Fact] public async Task CreateEntry_ExpireAbsoluteExpirationRelativeToNow_CacheIsEmpty() { - var expiration = TimeSpan.FromSeconds(3); var options = new MemoryCacheOptions(); var cache = new MemoryCache(options); using(var entry = cache.CreateEntry("key")){ - entry.AbsoluteExpirationRelativeToNow = expiration; + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3); entry.Value = $"value"; } using (new AssertionScope()) { cache.Count.Should().Be(1); - await Task.Delay(expiration); + await Task.Delay(TimeSpan.FromSeconds(4)); var value = cache.TryGetValue("key", out var cacheEntry); value.Should().BeFalse(); From d178eba0e46bc9ae125fbc34c27e501749606f42 Mon Sep 17 00:00:00 2001 From: Leefrost Date: Thu, 16 Mar 2023 21:53:18 +0200 Subject: [PATCH 13/13] Added coverage for extensions --- .../InMemory/InMemoryCacheHandler.cs | 6 +-- .../Utils/HttpResponseMessageExtensions.cs | 4 +- .../InMemory/Clock/SystemClockTests.cs | 15 ++++++ tests/HttpClient.Cache.Tests/TestClock.cs | 8 --- .../HttpResponseMessageExtensionsTests.cs | 52 +++++++++++++++++++ .../Utils/HttpStatusCodeExtensionsTests.cs | 43 +++++++++++++++ 6 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 tests/HttpClient.Cache.Tests/InMemory/Clock/SystemClockTests.cs delete mode 100644 tests/HttpClient.Cache.Tests/TestClock.cs create mode 100644 tests/HttpClient.Cache.Tests/Utils/HttpResponseMessageExtensionsTests.cs create mode 100644 tests/HttpClient.Cache.Tests/Utils/HttpStatusCodeExtensionsTests.cs diff --git a/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs index f20091e..4198656 100644 --- a/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs +++ b/src/HttpClient.Cache/InMemory/InMemoryCacheHandler.cs @@ -64,7 +64,7 @@ protected override async Task SendAsync(HttpRequestMessage { if (await _responseCache.TryGetAsync(key, out var cachedData) && cachedData != default) { - var cachedResponse = request.PrepareCacheEntry(cachedData); + var cachedResponse = request.RestoreResponseFromCache(cachedData); StatsProvider.ReportHit(cachedResponse.StatusCode); return cachedResponse; @@ -82,9 +82,9 @@ protected override async Task SendAsync(HttpRequestMessage if (TimeSpan.Zero != absoluteExpirationRelativeToNow) { - var entry = await response.ToCacheEntry(); + var entry = await response.ToCacheDataAsync(); await _responseCache.TrySetAsync(key, entry, absoluteExpirationRelativeToNow); - return request.PrepareCacheEntry(entry); + return request.RestoreResponseFromCache(entry); } } diff --git a/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs b/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs index d81658e..4045fa8 100644 --- a/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs +++ b/src/HttpClient.Cache/Utils/HttpResponseMessageExtensions.cs @@ -2,7 +2,7 @@ public static class HttpResponseMessageExtensions { - public static async Task ToCacheEntry(this HttpResponseMessage response) + public static async Task ToCacheDataAsync(this HttpResponseMessage response) { var data = await response.Content.ReadAsByteArrayAsync(); var copy = new HttpResponseMessage @@ -16,7 +16,7 @@ public static async Task ToCacheEntry(this HttpResponseMessage respon return entry; } - public static HttpResponseMessage PrepareCacheEntry(this HttpRequestMessage request, CacheData cacheData) + public static HttpResponseMessage RestoreResponseFromCache(this HttpRequestMessage request, CacheData cacheData) { var response = cacheData.Response; response.Content = new ByteArrayContent(cacheData.Data); diff --git a/tests/HttpClient.Cache.Tests/InMemory/Clock/SystemClockTests.cs b/tests/HttpClient.Cache.Tests/InMemory/Clock/SystemClockTests.cs new file mode 100644 index 0000000..4377044 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/InMemory/Clock/SystemClockTests.cs @@ -0,0 +1,15 @@ +using FluentAssertions; +using HttpClient.Cache.InMemory.Clock; + +namespace HttpClient.Cache.Tests.InMemory.Clock; + +public class SystemClockTests +{ + [Fact] + public void GetTime_CheckCurrentTime_ReturnUtcNow() + { + var systemClock = new SystemClock(); + + systemClock.UtcNow.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1)); + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/TestClock.cs b/tests/HttpClient.Cache.Tests/TestClock.cs deleted file mode 100644 index ff50b74..0000000 --- a/tests/HttpClient.Cache.Tests/TestClock.cs +++ /dev/null @@ -1,8 +0,0 @@ -using HttpClient.Cache.InMemory.Clock; - -namespace HttpClient.Cache.Tests; - -public class TestClock : ISystemClock -{ - public DateTimeOffset UtcNow => DateTimeOffset.UtcNow + TimeSpan.FromDays(1); -} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/Utils/HttpResponseMessageExtensionsTests.cs b/tests/HttpClient.Cache.Tests/Utils/HttpResponseMessageExtensionsTests.cs new file mode 100644 index 0000000..8bc57c5 --- /dev/null +++ b/tests/HttpClient.Cache.Tests/Utils/HttpResponseMessageExtensionsTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using FluentAssertions.Execution; +using HttpClient.Cache.Utils; + +namespace HttpClient.Cache.Tests.Utils; + +public class HttpResponseMessageExtensionsTests +{ + [Fact] + public async Task ToCacheEntry_ConvertResponseToCacheData_ReturnCacheData() + { + var content = Encoding.UTF8.GetBytes("The message"); + var response = new HttpResponseMessage + { + Content = new ByteArrayContent(content), + ReasonPhrase = "Reason", + StatusCode = HttpStatusCode.Found, + Version = Version.Parse("1.0") + }; + + var cacheData = await response.ToCacheDataAsync(); + + cacheData.Data.Should().BeEquivalentTo(content); + cacheData.Response.Should().NotBeNull().And.NotBe(response); + } + + [Fact] + public void RestoreResponseFromCache_RestoreHttpResponse_ReturnHttpResponseMessage() + { + var content = Encoding.UTF8.GetBytes("The message"); + var cachedResponse = new HttpResponseMessage + { + Content = new ByteArrayContent(content), + ReasonPhrase = "Reason", + StatusCode = HttpStatusCode.Found, + Version = Version.Parse("1.0") + }; + var cacheData = new CacheData(content, cachedResponse); + var newRequest = new HttpRequestMessage { Method = HttpMethod.Get }; + + var restoredResponse = newRequest.RestoreResponseFromCache(cacheData); + + using (new AssertionScope()) + { + restoredResponse.Should().NotBeNull(); + restoredResponse.Content.Should().NotBeNull(); + restoredResponse.RequestMessage.Should().Be(newRequest); + } + } +} \ No newline at end of file diff --git a/tests/HttpClient.Cache.Tests/Utils/HttpStatusCodeExtensionsTests.cs b/tests/HttpClient.Cache.Tests/Utils/HttpStatusCodeExtensionsTests.cs new file mode 100644 index 0000000..d02acdc --- /dev/null +++ b/tests/HttpClient.Cache.Tests/Utils/HttpStatusCodeExtensionsTests.cs @@ -0,0 +1,43 @@ +using System.Net; +using FluentAssertions; +using HttpClient.Cache.Utils; + +namespace HttpClient.Cache.Tests.Utils; + +public class HttpStatusCodeExtensionsTests +{ + private readonly Dictionary _cachingTime = new() + { + { HttpStatusCode.OK, TimeSpan.FromMinutes(1) }, { HttpStatusCode.BadRequest, TimeSpan.FromSeconds(30) } + }; + + [Fact] + public void GetAbsoluteExpirationRelativeToNow_ConvertExistedCodeToTimeSpan_Return1min() + { + const HttpStatusCode existingCode = HttpStatusCode.OK; + + var relativeCacheTime = existingCode.GetAbsoluteExpirationRelativeToNow(_cachingTime); + + relativeCacheTime.Should().Be(TimeSpan.FromMinutes(1)); + } + + [Fact] + public void GetAbsoluteExpirationRelativeToNow_ConvertCloseCodeToParentCode_Return30s() + { + const HttpStatusCode existingCode = HttpStatusCode.Gone; + + var relativeCacheTime = existingCode.GetAbsoluteExpirationRelativeToNow(_cachingTime); + + relativeCacheTime.Should().Be(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void GetAbsoluteExpirationRelativeToNow_GetDefaultCacheIfCodeUnknown_Return1Day() + { + const HttpStatusCode existingCode = HttpStatusCode.InternalServerError; + + var relativeCacheTime = existingCode.GetAbsoluteExpirationRelativeToNow(_cachingTime); + + relativeCacheTime.Should().Be(TimeSpan.FromDays(1)); + } +} \ No newline at end of file