diff --git a/HzCache.Benchmarks/HzCache.Benchmarks.csproj b/HzCache.Benchmarks/HzCache.Benchmarks.csproj index 02a5af4..e2d1630 100644 --- a/HzCache.Benchmarks/HzCache.Benchmarks.csproj +++ b/HzCache.Benchmarks/HzCache.Benchmarks.csproj @@ -15,7 +15,7 @@ - + diff --git a/HzCache.Benchmarks/Program.cs b/HzCache.Benchmarks/Program.cs index a361ab6..bc696b4 100644 --- a/HzCache.Benchmarks/Program.cs +++ b/HzCache.Benchmarks/Program.cs @@ -2,7 +2,7 @@ using System.Runtime.Caching; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; -using hzcache; +using HzCache; using HzCache.Benchmarks; BenchmarkRunner.Run(); diff --git a/HzCache.Benchmarks/WithRedisInvalidation.cs b/HzCache.Benchmarks/WithRedisInvalidation.cs index f3e9afb..6492c16 100644 --- a/HzCache.Benchmarks/WithRedisInvalidation.cs +++ b/HzCache.Benchmarks/WithRedisInvalidation.cs @@ -1,8 +1,6 @@ using System.Collections.Concurrent; using System.Runtime.Caching; using BenchmarkDotNet.Attributes; -using hzcache; -using RedisBackplaneMemoryCache; namespace HzCache.Benchmarks { @@ -10,7 +8,7 @@ namespace HzCache.Benchmarks [MemoryDiagnoser] public class WithRedisInvalidation { - private static readonly IDetailedHzCache _hzCache = new RedisBackplaneHzCache(new RedisBackplaneMemoryMemoryCacheOptions + private static readonly IDetailedHzCache _hzCache = new RedisBackedHzCache(new RedisBackedHzCacheOptions { applicationCachePrefix = "benchmark", redisConnectionString = "localhost", diff --git a/HzMemoryCache/HzCacheMemoryLocker.cs b/HzMemoryCache/HzCacheMemoryLocker.cs index 4d43265..982ddb9 100644 --- a/HzMemoryCache/HzCacheMemoryLocker.cs +++ b/HzMemoryCache/HzCacheMemoryLocker.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -namespace hzcache +namespace HzCache { public class HzCacheMemoryLockerOptions { diff --git a/HzMemoryCache/HzMemoryCache.cs b/HzMemoryCache/HzMemoryCache.cs index f0c4129..32a2eb6 100644 --- a/HzMemoryCache/HzMemoryCache.cs +++ b/HzMemoryCache/HzMemoryCache.cs @@ -12,15 +12,15 @@ using System.Threading.Tasks.Dataflow; using Microsoft.Extensions.Logging; -namespace hzcache +namespace HzCache { /// /// Simple MemoryCache alternative. Basically a concurrent dictionary with expiration and cache value change /// notifications. /// - public class HzMemoryCache : IEnumerable>, IDisposable, IDetailedHzCache + public partial class HzMemoryCache : IEnumerable>, IDisposable, IDetailedHzCache { - private static readonly IPropagatorBlock> updateChecksumAndSerializeQueue = CreateBuffer(TimeSpan.FromMilliseconds(10), 100); + private static readonly IPropagatorBlock> updateChecksumAndSerializeQueue = CreateBuffer(TimeSpan.FromMilliseconds(35), 100); private static readonly SemaphoreSlim globalStaticLock = new(1); private readonly Timer cleanUpTimer; private readonly ConcurrentDictionary dictionary = new(); @@ -42,6 +42,7 @@ public HzMemoryCache(HzCacheOptions? options = null) } public int Count => dictionary.Count; + public long SizeInBytes => dictionary.Values.Sum(ttlv => ttlv.sizeInBytes); public void RemoveByPattern(string pattern, bool sendNotification = true) @@ -135,7 +136,6 @@ public void Set(string key, T? value) /// public void Set(string key, T? value, TimeSpan ttl) { - // Console.WriteLine($"[{options.instanceId}] Set {key} with TTL {ttl} and value {value}"); var v = new TTLValue(key, value, ttl, updateChecksumAndSerializeQueue, options.notificationType, (tv, objectData) => NotifyItemChange(key, CacheItemChangeType.AddOrUpdate, tv, objectData)); dictionary[key] = v; @@ -171,12 +171,13 @@ public void Set(string key, T? value, TimeSpan ttl) ReleaseLock(factoryLock, "GET", key); return value; } - + public IList GetOrSetBatch(IList keys, Func, List>> valueFactory) { return GetOrSetBatch(keys, valueFactory, options.defaultTTL); } - public IList GetOrSetBatch(IList keys, Func, List>> valueFactory, TimeSpan ttl) + + public IList GetOrSetBatch(IList keys, Func, List>> valueFactory, TimeSpan ttl) { var cachedItems = keys.Select(key => new KeyValuePair(key, Get(key))); var missingKeys = cachedItems.Where(kvp => IsNullOrDefault(kvp.Value)).Select(kvp => kvp.Key).ToList(); @@ -189,6 +190,7 @@ public IList GetOrSetBatch(IList keys, Func, List GetOrSetBatch(IList keys, Func, List v.Value is not null).Select(kv => kv.Value).ToList(); } + /// /// @see /// @@ -211,14 +214,14 @@ public bool Remove(string key) /// /// @see /// - public bool Remove(string key, bool sendBackplaneNotification = true, Func? skipRemoveIfEqualFunc = null) + public bool Remove(string key, bool sendBackplaneNotification, Func? skipRemoveIfEqualFunc = null) { return RemoveItem(key, CacheItemChangeType.Remove, sendBackplaneNotification, skipRemoveIfEqualFunc); } public CacheStatistics GetStatistics() { - return new CacheStatistics {Counts = Count, SizeInBytes = 0}; + return new CacheStatistics {Counts = Count, SizeInBytes = SizeInBytes}; } /// @@ -301,7 +304,6 @@ private async Task ProcessExpiredEviction() private bool RemoveItem(string key, CacheItemChangeType changeType, bool sendNotification, Func? areEqualFunc = null) { - // Console.WriteLine($"[{options.instanceId}] Delete {key}"); var result = !(!dictionary.TryGetValue(key, out TTLValue ttlValue) || (areEqualFunc != null && areEqualFunc.Invoke(ttlValue.checksum))); if (result) @@ -323,8 +325,7 @@ private bool RemoveItem(string key, CacheItemChangeType changeType, bool sendNot private void NotifyItemChange(string key, CacheItemChangeType changeType, TTLValue ttlValue, byte[]? objectData = null, bool isPattern = false) { - // Console.WriteLine($"Publishing {changeType} for {key} and pattern {isPattern}"); - options.valueChangeListener.Invoke(key, changeType, ttlValue, objectData, isPattern); + options.valueChangeListener(key, changeType, ttlValue, objectData, isPattern); } public void SetRaw(string key, TTLValue value) diff --git a/HzMemoryCache/HzMemoryCache.csproj b/HzMemoryCache/HzMemoryCache.csproj index 4dc3266..ef635ab 100644 --- a/HzMemoryCache/HzMemoryCache.csproj +++ b/HzMemoryCache/HzMemoryCache.csproj @@ -16,7 +16,7 @@ True true latestmajor - hzcache + HzCache diff --git a/HzMemoryCache/HzMemoryCacheAsync.cs b/HzMemoryCache/HzMemoryCacheAsync.cs new file mode 100644 index 0000000..564aa19 --- /dev/null +++ b/HzMemoryCache/HzMemoryCacheAsync.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace HzCache +{ + public partial class HzMemoryCache + { + public Task SetAsync(string key, T? value) + { + return SetAsync(key, value, options.defaultTTL); + } + + public Task SetAsync(string key, T? value, TimeSpan ttl) + { + Set(key, value, ttl); + return Task.CompletedTask; + } + + public async Task GetOrSetAsync(string key, Func> valueFactory, TimeSpan ttl, long maxMsToWaitForFactory = 10000) + { + var value = Get(key); + if (!IsNullOrDefault(value)) + { + return value; + } + + options.logger?.LogDebug("Cache miss for key {Key}, calling value factory", key); + + var factoryLock = memoryLocker.AcquireLock(options.applicationCachePrefix, options.instanceId, "GET", key, TimeSpan.FromMilliseconds(maxMsToWaitForFactory), + options.logger, CancellationToken.None); + if (factoryLock is null) + { + options.logger?.LogDebug("Could not acquire lock for key {Key}, returning default value", key); + return value; + } + + value = await valueFactory(key); + var ttlValue = new TTLValue(key, value, ttl, updateChecksumAndSerializeQueue, options.notificationType, (tv, objectData) => + { + NotifyItemChange(key, CacheItemChangeType.AddOrUpdate, tv, objectData); + }); + dictionary[key] = ttlValue; + ReleaseLock(factoryLock, "GET", key); + return value; + } + + + public async Task> GetOrSetBatchAsync(IList keys, Func, Task>>> valueFactory) + { + return await GetOrSetBatchAsync(keys, valueFactory, options.defaultTTL); + } + + public async Task> GetOrSetBatchAsync(IList keys, Func, Task>>> valueFactory, TimeSpan ttl) + { + var cachedItems = keys.Select(key => new KeyValuePair(key, Get(key))); + var missingKeys = cachedItems.Where(kvp => IsNullOrDefault(kvp.Value)).Select(kvp => kvp.Key).ToList(); + var factoryRetrievedItems = (await valueFactory(missingKeys)).ToDictionary(kv => kv.Key, kv => kv.Value); + + return cachedItems.Select(kv => + { + T? value = default; + if (kv.Value != null) + { + value = kv.Value; + } + + if (kv.Value == null) + { + factoryRetrievedItems.TryGetValue(kv.Key, out value); + Set(kv.Key, value, ttl); + } + + return new KeyValuePair(kv.Key, value); + }).Where(v => v.Value is not null).Select(kv => kv.Value).ToList(); + } + + public async Task ClearAsync() + { + var kvps = dictionary.ToArray(); + dictionary.Clear(); + foreach (var kv in kvps) + { + NotifyItemChange("*", CacheItemChangeType.Remove, null, null, true); + } + } + + public async Task RemoveAsync(string key) + { + return await RemoveAsync(key, options.notificationType != NotificationType.None); + } + + public async Task RemoveByPatternAsync(string pattern, bool sendNotification = true) + { + var myPattern = pattern; + if (pattern[0] != '*') + { + myPattern = "^" + pattern; + } + + var re = new Regex(myPattern.Replace("*", ".*")); + var victims = dictionary.Keys.Where(k => re.IsMatch(k)).ToList(); + victims.AsParallel().ForAll(async key => + { + await RemoveItemAsync(key, CacheItemChangeType.Remove, false); + }); + if (sendNotification) + { + NotifyItemChange(pattern, CacheItemChangeType.Remove, null, null, true); + } + } + + public async Task GetAsync(string key) + { + var defaultValue = default(T); + + if (!dictionary.TryGetValue(key, out var ttlValue)) + { + return defaultValue; + } + + if (ttlValue.IsExpired()) //found but expired + { + return defaultValue; + } + + if (options.evictionPolicy == EvictionPolicy.LRU) + { + ttlValue.UpdateTimeToKill(); + } + + if (ttlValue.value is T o) + { + return o; + } + + return default; + } + + public async Task RemoveAsync(string key, bool sendBackplaneNotification = true, Func? skipRemoveIfEqualFunc = null) + { + return await RemoveItemAsync(key, CacheItemChangeType.Remove, sendBackplaneNotification, skipRemoveIfEqualFunc); + } + + private async Task RemoveItemAsync(string key, CacheItemChangeType changeType, bool sendNotification, Func? areEqualFunc = null) + { + var result = !(!dictionary.TryGetValue(key, out var ttlValue) || (areEqualFunc != null && areEqualFunc.Invoke(ttlValue.checksum))); + + if (result) + { + result = dictionary.TryRemove(key, out ttlValue); + if (result) + { + result = !ttlValue.IsExpired(); + } + } + + if (sendNotification) + { + NotifyItemChange(key, changeType, ttlValue); + } + + return result; + } + } +} diff --git a/HzMemoryCache/IHzCache.cs b/HzMemoryCache/IHzCache.cs index 10e5c9e..26901bc 100644 --- a/HzMemoryCache/IHzCache.cs +++ b/HzMemoryCache/IHzCache.cs @@ -1,9 +1,10 @@ #nullable enable using System; using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; -namespace hzcache +namespace HzCache { public enum CacheItemChangeType { @@ -84,7 +85,7 @@ public class HzCacheOptions /// public EvictionPolicy evictionPolicy { get; set; } = EvictionPolicy.LRU; - public ILogger? logger { get; set; } + public ILogger? logger { get; set; } } public interface IHzCache @@ -96,6 +97,8 @@ public interface IHzCache /// void RemoveByPattern(string pattern, bool sendNotification = true); + Task RemoveByPatternAsync(string pattern, bool sendNotification = true); + /// /// Attempts to get a value by key /// @@ -103,6 +106,8 @@ public interface IHzCache /// True if value exists, otherwise false T? Get(string key); + Task GetAsync(string key); + /// /// Attempts to add a key/value item /// @@ -111,6 +116,8 @@ public interface IHzCache /// True if value was added, otherwise false (already exists) void Set(string key, T? value); + Task SetAsync(string key, T? value); + /// /// Adds a key/value pair. This method could potentially be optimized, but not sure as of now. /// The initial "TryGetValue" likely costs a bit, which adds time and makes it slower than @@ -123,6 +130,8 @@ public interface IHzCache /// True if value was added, otherwise false (already exists) void Set(string key, T? value, TimeSpan ttl); + Task SetAsync(string key, T? value, TimeSpan ttl); + /// /// Adds a key/value pair by using the specified function if the key does not already exist, or returns the existing /// value if the key exists. @@ -133,6 +142,8 @@ public interface IHzCache /// The maximum amount of time (in ms) to wait for backend. Default is 10.000ms T? GetOrSet(string key, Func valueFactory, TimeSpan ttl, long maxMsToWaitForFactory = 10000); + Task GetOrSetAsync(string key, Func> valueFactory, TimeSpan ttl, long maxMsToWaitForFactory = 10000); + /// /// Get a list of cache items by key list. If the key doesn't exist, it will be added by the valueFactory which /// must return a list of KeyValuePairs where the Key is the cache key and the value is the value to add. @@ -143,6 +154,8 @@ public interface IHzCache /// A list of items matching the keys. public IList GetOrSetBatch(IList keys, Func, List>> valueFactory); + public Task> GetOrSetBatchAsync(IList keys, Func, Task>>> valueFactory); + /// /// Get a list of cache items by key list. If the key doesn't exist, it will be added by the valueFactory which /// must return a list of KeyValuePairs where the Key is the cache key and the value is the value to add. @@ -154,11 +167,16 @@ public interface IHzCache /// A list of items matching the keys. public IList GetOrSetBatch(IList keys, Func, List>> valueFactory, TimeSpan ttl); + public Task> GetOrSetBatchAsync(IList keys, Func, Task>>> valueFactory, TimeSpan ttl); + /// /// Tries to remove item with the specified key, also returns the object removed in an "out" var /// /// The key of the element to remove bool Remove(string key); + + Task ClearAsync(); + Task RemoveAsync(string key); } public interface IDetailedHzCache : IHzCache @@ -181,7 +199,7 @@ public interface IDetailedHzCache : IHzCache /// The key of the element to remove /// Send backplane notification or not /// If function returns true, skip removing the entry - bool Remove(string key, bool sendBackplaneNotification = true, Func? checksumEqualsFunc = null); + bool Remove(string key, bool sendBackplaneNotification, Func? checksumEqualsFunc = null); CacheStatistics GetStatistics(); } @@ -190,5 +208,10 @@ public class CacheStatistics { public long Counts { get; set; } public long SizeInBytes { get; set; } + + public override string ToString() + { + return $"Number of keys: {Counts}, SizeInBytes: {SizeInBytes}"; + } } } diff --git a/HzMemoryCache/TTLValue.cs b/HzMemoryCache/TTLValue.cs index 20f6070..955b17d 100644 --- a/HzMemoryCache/TTLValue.cs +++ b/HzMemoryCache/TTLValue.cs @@ -1,11 +1,14 @@ #nullable enable using System; using System.Collections.Generic; +using System.IO; +using System.IO.Compression; using System.Security.Cryptography; +using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; using Utf8Json; -namespace hzcache +namespace HzCache { public class TTLValue { @@ -46,20 +49,59 @@ public TTLValue(string key, object? value, TimeSpan ttl, IPropagatorBlock(byte[] compressedData) { - // var target = new byte[LZ4Codec.MaximumOutputSize(compressedData.Length)]; - // LZ4Codec.Decode( - // compressedData, 0, compressedData.Length, - // target, 0, target.Length); var redisValue = JsonSerializer.Deserialize(compressedData); + using Stream valueStream = new MemoryStream(false ? Decompress(redisValue.valueJson) : redisValue.valueJson); + return new TTLValue + { + checksum = redisValue.checksum, + key = redisValue.key, + ttlInMs = redisValue.ttlInMs, + value = JsonSerializer.Deserialize(valueStream), + sizeInBytes = redisValue.valueJson.Length, + timestampCreated = redisValue.timestampCreated, + tickCountWhenToKill = redisValue.tickCountWhenToKill, + absoluteExpireTime = redisValue.absoluteExpireTime + }; + } + + public static async Task FromRedisValueAsync(byte[] compressedData) + { + using Stream stream = new MemoryStream(compressedData); + var redisValue = await JsonSerializer.DeserializeAsync(stream); + using Stream valueStream = new MemoryStream(false ? Decompress(redisValue.valueJson) : redisValue.valueJson); return new TTLValue { checksum = redisValue.checksum, key = redisValue.key, ttlInMs = redisValue.ttlInMs, - value = JsonSerializer.Deserialize(redisValue.valueJson), + value = await JsonSerializer.DeserializeAsync(valueStream), + sizeInBytes = redisValue.valueJson.Length, timestampCreated = redisValue.timestampCreated, tickCountWhenToKill = redisValue.tickCountWhenToKill, absoluteExpireTime = redisValue.absoluteExpireTime @@ -71,9 +113,10 @@ public void UpdateChecksum() using var md5 = MD5.Create(); var valueJson = JsonSerializer.Serialize(value); checksum = BitConverter.ToString(md5.ComputeHash(valueJson)); + sizeInBytes = valueJson.Length; var redisValue = new TTLRedisValue { - valueJson = valueJson, + valueJson = false ? Compress(valueJson) : valueJson, key = key, timestampCreated = timestampCreated, absoluteExpireTime = absoluteExpireTime, diff --git a/RedisBackplaneHzCache/README.md b/RedisBackedHzCache/README.md similarity index 100% rename from RedisBackplaneHzCache/README.md rename to RedisBackedHzCache/README.md diff --git a/RedisBackplaneHzCache/RedisBackplaneHzCache.cs b/RedisBackedHzCache/RedisBackedHzCache.cs similarity index 87% rename from RedisBackplaneHzCache/RedisBackplaneHzCache.cs rename to RedisBackedHzCache/RedisBackedHzCache.cs index 0f8c51f..b262326 100644 --- a/RedisBackplaneHzCache/RedisBackplaneHzCache.cs +++ b/RedisBackedHzCache/RedisBackedHzCache.cs @@ -1,30 +1,28 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text; -using hzcache; using Microsoft.Extensions.Logging; using StackExchange.Redis; using Utf8Json; -namespace RedisBackplaneMemoryCache +namespace HzCache { - public class RedisBackplaneMemoryMemoryCacheOptions : HzCacheOptions + public class RedisBackedHzCacheOptions : HzCacheOptions { public string redisConnectionString { get; set; } - public string instanceId { get; set; } - public bool useRedisAs2ndLevelCache { get; set; } = false; + public bool useRedisAs2ndLevelCache { get; set; } } - public class RedisBackplaneHzCache : IDetailedHzCache + public partial class RedisBackedHzCache : IDetailedHzCache { private readonly HzMemoryCache hzCache; private readonly string instanceId = Guid.NewGuid().ToString(); - private readonly RedisBackplaneMemoryMemoryCacheOptions options; - private readonly ConnectionMultiplexer redis; + private readonly RedisBackedHzCacheOptions options; private readonly IDatabase redisDb; - public RedisBackplaneHzCache(RedisBackplaneMemoryMemoryCacheOptions options) + public RedisBackedHzCache(RedisBackedHzCacheOptions options) { this.options = options; if (this.options.redisConnectionString != null) @@ -37,7 +35,7 @@ public RedisBackplaneHzCache(RedisBackplaneMemoryMemoryCacheOptions options) instanceId = options.instanceId; } - redis = ConnectionMultiplexer.Connect(options.redisConnectionString); + var redis = ConnectionMultiplexer.Connect(options.redisConnectionString); redisDb = redis.GetDatabase(); hzCache = new HzMemoryCache(new HzCacheOptions { @@ -49,20 +47,20 @@ public RedisBackplaneHzCache(RedisBackplaneMemoryMemoryCacheOptions options) { options.valueChangeListener?.Invoke(key, changeType, ttlValue, objectData, isPattern); var redisChannel = new RedisChannel(options.applicationCachePrefix, RedisChannel.PatternMode.Auto); - // Console.WriteLine($"Publishing message {changeType} {this.options.applicationCachePrefix} {instanceId} {key} {ttlValue?.checksum} {ttlValue?.timestampCreated} {isPattern}"); var messageObject = new RedisInvalidationMessage(this.options.applicationCachePrefix, instanceId, key, ttlValue?.checksum, ttlValue?.timestampCreated, isPattern); redis.GetSubscriber().PublishAsync(redisChannel, new RedisValue(JsonSerializer.ToJsonString(messageObject))); var redisKey = GetRedisKey(key); if (changeType == CacheItemChangeType.AddOrUpdate) { - if (options.useRedisAs2ndLevelCache && objectData != null) + if (options.useRedisAs2ndLevelCache && objectData != null && ttlValue != null) { try { - options.logger?.LogTrace("Setting value for key {Key} in redis", key); + var stopwatch = Stopwatch.StartNew(); redisDb.StringSet(redisKey, objectData, TimeSpan.FromMilliseconds(ttlValue.absoluteExpireTime - DateTimeOffset.Now.ToUnixTimeMilliseconds())); + options.logger?.LogTrace("Writing value for key {Key} in redis took {Elapsed} ms", key, stopwatch.ElapsedMilliseconds); } catch (Exception e) { @@ -122,8 +120,6 @@ public RedisBackplaneHzCache(RedisBackplaneMemoryMemoryCacheOptions options) if (invalidationMessage.instanceId != instanceId) { - // Console.WriteLine( - // $"[{instanceId}] Received invalidation for key {invalidationMessage.key} from {invalidationMessage.instanceId}, isPattern: {invalidationMessage.isPattern}"); if (invalidationMessage.isPattern.HasValue && invalidationMessage.isPattern.Value) { hzCache.RemoveByPattern(invalidationMessage.key, false); @@ -151,14 +147,14 @@ public void Clear() hzCache.Clear(); } - public bool Remove(string key, bool sendBackplaneNotification = true, Func skipRemoveIfEqualFunc = null) + public bool Remove(string key, bool sendBackplaneNotification, Func skipRemoveIfEqualFunc = null) { return hzCache.Remove(key, sendBackplaneNotification, skipRemoveIfEqualFunc); } public CacheStatistics GetStatistics() { - throw new NotImplementedException(); + return hzCache.GetStatistics(); } public T Get(string key) @@ -166,7 +162,9 @@ public T Get(string key) var value = hzCache.Get(key); if (value == null && options.useRedisAs2ndLevelCache) { + var stopwatch = Stopwatch.StartNew(); var redisValue = redisDb.StringGet(GetRedisKey(key)); + options.logger?.LogTrace("Reading value for key {Key} in redis took {Elapsed} ms", key, stopwatch.ElapsedMilliseconds); if (!redisValue.IsNull) { var ttlValue = TTLValue.FromRedisValue(Encoding.ASCII.GetBytes(redisValue.ToString())); @@ -190,7 +188,6 @@ public void Set(string key, T value, TimeSpan ttl) public T GetOrSet(string key, Func valueFactory, TimeSpan ttl, long maxMsToWaitForFactory = 10000) { - // TODO: Fix this HUGE HOLE! return hzCache.GetOrSet(key, valueFactory, ttl, maxMsToWaitForFactory); } @@ -198,25 +195,26 @@ public IList GetOrSetBatch(IList keys, Func, List GetOrSetBatch(IList keys, Func, List>> valueFactory, TimeSpan ttl) { - Func, List>> redisFactory = (idList) => + Func, List>> redisFactory = idList => { // Create a list of redis keys from the list of cache keys var redisKeyList = idList.Select(GetRedisKey).Select(k => new RedisKey(k)).ToArray(); - + // Get all values from redis, non-existing values are returned as RedisValue where HasValue == false; var redisBatchResult = redisDb.StringGet(redisKeyList); - + // Create a list of key-value pairs from the redis key list and the redis batch result. Values not found will still have HasValue == false var redisKeyValueBatchResult = redisKeyList.Select((id, i) => new KeyValuePair(id, redisBatchResult[i])).ToList(); - + // Create a list of cache keys for which the value factory should be called var idsForFactoryCall = redisKeyValueBatchResult.Where(rb => !rb.Value.HasValue).Select(rb => rb.Key.ToString()).ToList(); - + // Call the value factory with the list of cache keys missing in redis and create a Dictionary for lookup. var factoryRetrievedValues = valueFactory.Invoke(idsForFactoryCall.Select(CacheKeyFromRedisKey).ToList()).ToDictionary(pair => pair.Key, pair => pair.Value); - + // Merge factory-retrieved values with the redis values return redisKeyValueBatchResult.Select(kv => { @@ -226,7 +224,7 @@ public IList GetOrSetBatch(IList keys, Func, List(Encoding.UTF8.GetBytes(kv.Value)); hzCache.SetRaw(cacheKey, ttlValue); - value = (T) ttlValue.value; + value = (T)ttlValue.value; } else if (factoryRetrievedValues.TryGetValue(cacheKey, out var factoryValue)) { @@ -236,9 +234,9 @@ public IList GetOrSetBatch(IList keys, Func, List(cacheKey, value); }).ToList(); - }; return hzCache.GetOrSetBatch(keys, redisFactory, ttl); } @@ -252,6 +250,7 @@ private string GetRedisKey(string cacheKey) { return $"{options.applicationCachePrefix}:{cacheKey}"; } + private string CacheKeyFromRedisKey(string redisKey) { return redisKey.Substring(options.applicationCachePrefix.Length + 1); diff --git a/RedisBackplaneHzCache/RedisBackplaneHzCache.csproj b/RedisBackedHzCache/RedisBackedHzCache.csproj similarity index 89% rename from RedisBackplaneHzCache/RedisBackplaneHzCache.csproj rename to RedisBackedHzCache/RedisBackedHzCache.csproj index 306f70c..cab87f4 100644 --- a/RedisBackplaneHzCache/RedisBackplaneHzCache.csproj +++ b/RedisBackedHzCache/RedisBackedHzCache.csproj @@ -2,7 +2,7 @@ netstandard2.0 - RedisBackplaneHzCache + RedisBackedHzCache RedisBackplaneHzCache Anders Heintz HzCache @@ -13,7 +13,7 @@ Library True true - RedisBackplaneMemoryCache + HzCache diff --git a/RedisBackedHzCache/RedisBackedHzCacheAsync.cs b/RedisBackedHzCache/RedisBackedHzCacheAsync.cs new file mode 100644 index 0000000..6bc4945 --- /dev/null +++ b/RedisBackedHzCache/RedisBackedHzCacheAsync.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace HzCache +{ + public partial class RedisBackedHzCache + { + public async Task RemoveByPatternAsync(string pattern, bool sendNotification = true) + { + await hzCache.RemoveByPatternAsync(pattern, sendNotification); + } + + public async Task GetAsync(string key) + { + var value = await hzCache.GetAsync(key); + if (value == null && options.useRedisAs2ndLevelCache) + { + var stopwatch = Stopwatch.StartNew(); + var redisValue = await redisDb.StringGetAsync(GetRedisKey(key)); + options.logger?.LogTrace("Redis get for key {Key} took {Elapsed} ms", key, stopwatch.ElapsedMilliseconds); + stopwatch.Restart(); + if (!redisValue.IsNull) + { + var ttlValue = await TTLValue.FromRedisValueAsync(Encoding.ASCII.GetBytes(redisValue.ToString())); + options.logger?.LogTrace("Deerialize {Key} took {Elapsed} ms", key, stopwatch.ElapsedMilliseconds); + stopwatch.Restart(); + hzCache.SetRaw(key, ttlValue); + return (T)ttlValue.value; + } + } + + return value; + } + + public async Task SetAsync(string key, T value) + { + await hzCache.SetAsync(key, value); + } + + public async Task SetAsync(string key, T value, TimeSpan ttl) + { + await hzCache.SetAsync(key, value, ttl); + } + + public async Task GetOrSetAsync(string key, Func> valueFactory, TimeSpan ttl, long maxMsToWaitForFactory = 10000) + { + return await hzCache.GetOrSetAsync(key, valueFactory, ttl, maxMsToWaitForFactory); + } + + public async Task> GetOrSetBatchAsync(IList keys, Func, Task>>> valueFactory) + { + return await GetOrSetBatchAsync(keys, valueFactory, options.defaultTTL); + } + + public async Task> GetOrSetBatchAsync(IList keys, Func, Task>>> valueFactory, TimeSpan ttl) + { + Func, Task>>> redisFactory = async idList => + { + // Create a list of redis keys from the list of cache keys + var redisKeyList = idList.Select(GetRedisKey).Select(k => new RedisKey(k)).ToArray(); + + // Get all values from redis, non-existing values are returned as RedisValue where HasValue == false; + var redisBatchResult = redisDb.StringGet(redisKeyList); + + // Create a list of key-value pairs from the redis key list and the redis batch result. Values not found will still have HasValue == false + var redisKeyValueBatchResult = redisKeyList.Select((id, i) => new KeyValuePair(id, redisBatchResult[i])).ToList(); + + // Create a list of cache keys for which the value factory should be called + var idsForFactoryCall = redisKeyValueBatchResult.Where(rb => !rb.Value.HasValue).Select(rb => rb.Key.ToString()).ToList(); + + // Call the value factory with the list of cache keys missing in redis and create a Dictionary for lookup. + var factoryRetrievedValues = + (await valueFactory.Invoke(idsForFactoryCall.Select(CacheKeyFromRedisKey).ToList())).ToDictionary(pair => pair.Key, pair => pair.Value); + + // Merge factory-retrieved values with the redis values + return redisKeyValueBatchResult.Select(kv => + { + var cacheKey = CacheKeyFromRedisKey(kv.Key); + T value; + if (kv.Value.HasValue) + { + var ttlValue = TTLValue.FromRedisValue(Encoding.UTF8.GetBytes(kv.Value)); + hzCache.SetRaw(cacheKey, ttlValue); + value = (T)ttlValue.value; + } + else if (factoryRetrievedValues.TryGetValue(cacheKey, out var factoryValue)) + { + value = factoryValue; + } + else + { + value = default; + } + + return new KeyValuePair(cacheKey, value); + }).ToList(); + }; + return await hzCache.GetOrSetBatchAsync(keys, redisFactory, ttl); + } + + public async Task ClearAsync() + { + await hzCache.ClearAsync(); + } + + public async Task RemoveAsync(string key) + { + return await hzCache.RemoveAsync(key); + } + } +} diff --git a/RedisBackplaneHzCache/RedisInvalidationMessage.cs b/RedisBackedHzCache/RedisInvalidationMessage.cs similarity index 88% rename from RedisBackplaneHzCache/RedisInvalidationMessage.cs rename to RedisBackedHzCache/RedisInvalidationMessage.cs index 7ce6f01..5ff85c8 100644 --- a/RedisBackplaneHzCache/RedisInvalidationMessage.cs +++ b/RedisBackedHzCache/RedisInvalidationMessage.cs @@ -1,4 +1,4 @@ -namespace RedisBackplaneMemoryCache +namespace HzCache { public class RedisInvalidationMessage { @@ -15,7 +15,7 @@ public RedisInvalidationMessage(string applicationCachePrefix, string instanceId public string applicationCachePrefix { get; } public string instanceId { get; set; } public string key { get; set; } - public bool? isPattern { get; set; } = false; + public bool? isPattern { get; set; } public string checksum { get; set; } public long? timestamp { get; set; } } diff --git a/RedisIDistributedCache/RedisIDistributedCache.cs b/RedisIDistributedCache/RedisIDistributedCache.cs index 6c696b3..3202be7 100644 --- a/RedisIDistributedCache/RedisIDistributedCache.cs +++ b/RedisIDistributedCache/RedisIDistributedCache.cs @@ -14,8 +14,8 @@ public class RedisIDistributedCacheOptions public class RedisIDistributedCache : IDistributedCache { - private RedisIDistributedCacheOptions options; - private IDatabase redis; + private readonly RedisIDistributedCacheOptions options; + private readonly IDatabase redis; public RedisIDistributedCache(RedisIDistributedCacheOptions options) { @@ -30,38 +30,32 @@ public byte[] Get(string key) { return null; } + var ttlValue = JsonSerializer.Deserialize(redisValue.ToString()); return ttlValue.value; } - public async Task GetAsync(string key, CancellationToken token = new CancellationToken()) + public async Task GetAsync(string key, CancellationToken token = new()) { var redisValue = await redis.StringGetAsync(key); if (!redisValue.HasValue) { return null; } + var ttlValue = JsonSerializer.Deserialize(redisValue.ToString()); return ttlValue.value; } public void Set(string key, byte[] value, DistributedCacheEntryOptions cacheOptions) { - var ttlValue = new TTLValue - { - value = value, - slidingExpiration = cacheOptions.SlidingExpiration?.TotalMilliseconds ?? 0 - }; + var ttlValue = new TTLValue {value = value, slidingExpiration = cacheOptions.SlidingExpiration?.TotalMilliseconds ?? 0}; redis.StringSet(key, JsonSerializer.Serialize(ttlValue), cacheOptions.AbsoluteExpirationRelativeToNow); } - public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions cacheOptions, CancellationToken token = new CancellationToken()) + public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions cacheOptions, CancellationToken token = new()) { - var ttlValue = new TTLValue - { - value = value, - slidingExpiration = cacheOptions.SlidingExpiration?.TotalMilliseconds ?? 0 - }; + var ttlValue = new TTLValue {value = value, slidingExpiration = cacheOptions.SlidingExpiration?.TotalMilliseconds ?? 0}; await redis.StringSetAsync(key, JsonSerializer.Serialize(ttlValue), cacheOptions.AbsoluteExpirationRelativeToNow); } @@ -72,25 +66,21 @@ public void Refresh(string key) { return; } + var ttlValue = JsonSerializer.Deserialize(redisValue.ToString()); - Set(key, ttlValue.value, new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromMilliseconds(ttlValue.slidingExpiration) - }); + Set(key, ttlValue.value, new DistributedCacheEntryOptions {SlidingExpiration = TimeSpan.FromMilliseconds(ttlValue.slidingExpiration)}); } - public async Task RefreshAsync(string key, CancellationToken token = new CancellationToken()) + public async Task RefreshAsync(string key, CancellationToken token = new()) { var redisValue = await redis.StringGetAsync(key); if (!redisValue.HasValue) { return; } + var ttlValue = JsonSerializer.Deserialize(redisValue.ToString()); - await SetAsync(key, ttlValue.value, new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromMilliseconds(ttlValue.slidingExpiration) - }, token); + await SetAsync(key, ttlValue.value, new DistributedCacheEntryOptions {SlidingExpiration = TimeSpan.FromMilliseconds(ttlValue.slidingExpiration)}, token); } public void Remove(string key) @@ -98,7 +88,7 @@ public void Remove(string key) redis.KeyDelete(key); } - public async Task RemoveAsync(string key, CancellationToken token = new CancellationToken()) + public async Task RemoveAsync(string key, CancellationToken token = new()) { await redis.KeyDeleteAsync(key); } diff --git a/RedisIDistributedCache/RedisIDistributedCache.csproj b/RedisIDistributedCache/RedisIDistributedCache.csproj index be8bd2e..73cf86b 100644 --- a/RedisIDistributedCache/RedisIDistributedCache.csproj +++ b/RedisIDistributedCache/RedisIDistributedCache.csproj @@ -1,25 +1,25 @@ - - netstandard2.0 - HzCache.RedisIDistributedCache - HzCache.RedisIDistributedCache - Anders Heintz - HzCache - https://github.com/aheintz/hzcache - 0.0.3 - Very simple IDistributedCache implementation for redis - latestmajor - Library - True - true - RedisIDistributedCache - + + netstandard2.0 + HzCache.RedisIDistributedCache + HzCache.RedisIDistributedCache + Anders Heintz + HzCache + https://github.com/aheintz/hzcache + 0.0.3 + Very simple IDistributedCache implementation for redis + latestmajor + Library + True + true + RedisIDistributedCache + - - - - - + + + + + diff --git a/UnitTests/IntegrationTests.cs b/UnitTests/IntegrationTests.cs index b1da7db..e1f5803 100644 --- a/UnitTests/IntegrationTests.cs +++ b/UnitTests/IntegrationTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; -using hzcache; -using RedisBackplaneMemoryCache; +using HzCache; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using StackExchange.Redis; namespace UnitTests @@ -13,6 +14,9 @@ public Mocko(long num) str = num.ToString(); } + public string guid { get; } = Guid.NewGuid().ToString(); + public long timestamp { get; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + public long num { get; set; } public string str { get; set; } } @@ -21,21 +25,19 @@ public class LargeMocko { public LargeMocko() { } - public LargeMocko(long numOfItems) + public LargeMocko(string key, long numOfItems) { + this.key = key; for (var i = 0; i < numOfItems; i++) { var m = new Mocko(i); - for (var j = 0; j < 100; j++) - { - m.str += Guid.NewGuid().ToString(); - } - + m.str = $"asd qwe zxc ert {i}"; items[i] = m; } } public Dictionary items { get; set; } = new(); + public string key { get; set; } } [TestClass] @@ -45,17 +47,17 @@ public class IntegrationTests [TestCategory("Integration")] public async Task TestRedisBackplaneInvalidation() { - var c1 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1"}); + var c1 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1"}); await Task.Delay(200); - var c2 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2"}); + var c2 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2"}); Console.WriteLine("Adding 1 to c1"); - c1.Set("1", new Mocko(1)); + await c1.SetAsync("1", new Mocko(1)); await Task.Delay(200); Console.WriteLine("Adding 1 to c2"); - c2.Set("1", new Mocko(2)); + await c2.SetAsync("1", new Mocko(2)); await Task.Delay(200); Assert.IsNull(c1.Get("1")); @@ -67,49 +69,84 @@ public async Task TestRedisBackplaneInvalidation() [TestCategory("Integration")] public async Task TestLargeObjects() { + var serviceProvider = new ServiceCollection() + .AddLogging( + builder => builder + .AddSimpleConsole(options => + { + options.IncludeScopes = true; + options.SingleLine = true; + options.TimestampFormat = "HH:mm:ss "; + }) + .SetMinimumLevel(LogLevel.Trace) + ) + .BuildServiceProvider(); + + var factory = serviceProvider.GetService(); + + var logger = factory.CreateLogger(); + var redis = ConnectionMultiplexer.Connect("localhost"); - var c1 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions + var c1 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions { - redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1", useRedisAs2ndLevelCache = true + redisConnectionString = "localhost", + applicationCachePrefix = "largeobjectstest", + instanceId = "c1", + useRedisAs2ndLevelCache = true, + notificationType = NotificationType.Async, + logger = factory.CreateLogger() }); await Task.Delay(200); - var c2 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions + var c2 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions { - redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2", useRedisAs2ndLevelCache = true + redisConnectionString = "localhost", + applicationCachePrefix = "largeobjectstest", + instanceId = "c2", + useRedisAs2ndLevelCache = true, + notificationType = NotificationType.Async, + logger = factory.CreateLogger() }); + var objectList = new List(); + for (var q = 1; q <= 10; q++) { - Console.WriteLine("Adding 1 to c1"); - var o = new LargeMocko(5000); + objectList.Add(new LargeMocko("o" + q, 50000)); + } + + foreach (var o in objectList) + { var s = Stopwatch.StartNew(); - c1.Set("" + q, o); - Console.WriteLine($"Time to write large object: {s.ElapsedMilliseconds} ms"); + await c1.SetAsync(o.key, o); + logger.LogInformation("Time to write large object {Q} to c1: {Elapsed} ms", o.key, s.ElapsedMilliseconds); s = Stopwatch.StartNew(); - while (c2.Get("" + q) == null && s.ElapsedMilliseconds < 30000) + while (await c2.GetAsync(o.key) == null && s.ElapsedMilliseconds < 30000) { - await Task.Delay(100); + await Task.Delay(50); } - Console.WriteLine($"Time from write to available on second node: {s.ElapsedMilliseconds} ms"); - Assert.IsNotNull(c2.Get("1")); + logger.LogInformation("Time from write to available on second node: {Elapsed} ms", s.ElapsedMilliseconds); + Assert.IsNotNull(await c2.GetAsync(o.key)); } - c1.Set("testinglargeretrieval", new LargeMocko(10000)); + logger.LogInformation("Starting testinglargeretrieval"); + await c1.SetAsync("testinglargeretrieval", new LargeMocko("testinglargeretrieval", 100000)); var stopWatch = Stopwatch.StartNew(); - while (!redis.GetDatabase().KeyExists("testinglargeretrieval") && stopWatch.ElapsedMilliseconds < 30000) + while (!await redis.GetDatabase().KeyExistsAsync("testinglargeretrieval") && stopWatch.ElapsedMilliseconds < 30000) { - await Task.Delay(100); + await Task.Delay(50); } stopWatch.Restart(); - c2.Get("testinglargeretrieval"); + await c2.GetAsync("testinglargeretrieval"); stopWatch.Stop(); - Assert.IsTrue(stopWatch.ElapsedMilliseconds < 500); - Console.WriteLine($"Reading from redis took {stopWatch.ElapsedMilliseconds} ms"); + Assert.IsTrue(stopWatch.ElapsedMilliseconds < 1500); + logger.LogInformation("Reading from redis took {Elapsed} ms", stopWatch.ElapsedMilliseconds); + logger.LogInformation("c1 stats: {Stats}", c1.GetStatistics()); + logger.LogInformation("c2 stats: {Stats}", c2.GetStatistics()); } @@ -118,50 +155,63 @@ public async Task TestLargeObjects() public async Task TestRedisClear() { var redis = ConnectionMultiplexer.Connect("localhost"); - var c1 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1"}); + var c1 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1"}); await Task.Delay(200); - var c2 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2"}); + var c2 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2"}); Console.WriteLine("Adding 1 to c1"); - c1.Set("1", new Mocko(1)); + await c1.SetAsync("1", new Mocko(1)); await Task.Delay(100); Console.WriteLine("Adding 2 to c2"); - c1.Set("2", new Mocko(2)); + await c1.SetAsync("2", new Mocko(2)); Console.WriteLine("Adding 2 to c2"); - c2.Set("3", new Mocko(3)); + await c2.SetAsync("3", new Mocko(3)); await Task.Delay(100); - c1.Clear(); + await c1.ClearAsync(); await Task.Delay(2000); - Assert.IsNull(c1.Get("1")); - Assert.IsNull(c2.Get("2")); - Assert.IsNull(c2.Get("3")); + Assert.IsNull(await c1.GetAsync("1")); + Assert.IsNull(await c2.GetAsync("2")); + Assert.IsNull(await c2.GetAsync("3")); } [TestMethod] [TestCategory("Integration")] public async Task TestRedisGet() { - var redis = ConnectionMultiplexer.Connect("localhost"); - var c1 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions - { - redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1", useRedisAs2ndLevelCache = true - }); + var c1 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1", useRedisAs2ndLevelCache = true}); await Task.Delay(200); - var c2 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions - { - redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2", useRedisAs2ndLevelCache = true - }); + var c2 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2", useRedisAs2ndLevelCache = true}); Console.WriteLine("Adding 1 to c1"); - c1.Set("1", new Mocko(10)); + await c1.SetAsync("1", new Mocko(10)); await Task.Delay(100); Console.WriteLine("Getting 1 from c2"); - Assert.IsNotNull(c2.Get("1")); + Assert.IsNotNull(await c2.GetAsync("1")); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task TestRedisGetOrSet() + { + var c1 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1", useRedisAs2ndLevelCache = true}); + await Task.Delay(200); + var c2 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2", useRedisAs2ndLevelCache = true}); + + Console.WriteLine("Adding 1 to c1"); + var v1 = c1.GetOrSet("1", _ => new Mocko(10), TimeSpan.FromMinutes(1)); + Assert.IsNotNull(v1); + Assert.IsTrue(c1.Get("1").num == 10); + await Task.Delay(100); + var c21 = await c2.GetAsync("1"); + Assert.IsTrue(c21.num == 10); + Assert.IsTrue(c21.guid != v1.guid); } @@ -170,49 +220,63 @@ public async Task TestRedisGet() public async Task TestRedisBackplaneDelete() { var redis = ConnectionMultiplexer.Connect("localhost"); - var c1 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1"}); + var c1 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c1"}); await Task.Delay(200); - var c2 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2"}); + var c2 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "test", instanceId = "c2"}); Console.WriteLine("Adding 1 to c1"); - c1.Set("1", new Mocko(1)); + await c1.SetAsync("1", new Mocko(1)); await Task.Delay(100); Console.WriteLine("Adding 2 to c2"); - c1.Set("2", new Mocko(2)); + await c1.SetAsync("2", new Mocko(2)); await Task.Delay(100); Console.WriteLine("Delete 1 from c2"); - c2.Remove("1"); + await c2.RemoveAsync("1"); await Task.Delay(300); - Assert.IsNull(c1.Get("1")); - Assert.IsNotNull(c1.Get("2")); + Assert.IsNull(await c1.GetAsync("1")); + Assert.IsNotNull(await c1.GetAsync("2")); } - + [TestMethod] [TestCategory("Integration")] public async Task TestRedisBatchGet() { var redis = ConnectionMultiplexer.Connect("localhost"); - var c1 = new RedisBackplaneHzCache( - new RedisBackplaneMemoryMemoryCacheOptions {redisConnectionString = "localhost", applicationCachePrefix = "batch2", instanceId = "c1", useRedisAs2ndLevelCache = true, defaultTTL = TimeSpan.FromSeconds(60)}); + var c1 = new RedisBackedHzCache( + new RedisBackedHzCacheOptions + { + redisConnectionString = "localhost", + applicationCachePrefix = "batch2", + instanceId = "c1", + useRedisAs2ndLevelCache = true, + defaultTTL = TimeSpan.FromSeconds(60) + }); - for (int i = 0; i < 10; i++) + for (var i = 0; i < 10; i++) { - c1.Set("key."+i, new Mocko(i)); + await c1.SetAsync("key." + i, new Mocko(i)); } await Task.Delay(3000); - var keys = new List { "key.0", "key.2", "key.20", "key.30", "key.40"}; - - var x = c1.GetOrSetBatch(keys, list => + var keys = new List { - return list.Select(k => k.Substring("key.".Length)).Select(int.Parse).Select(i => new KeyValuePair("key."+i, new Mocko(i))).ToList(); + "key.0", + "key.2", + "key.20", + "key.30", + "key.40" + }; + + var x = await c1.GetOrSetBatchAsync(keys, async list => + { + return list.Select(k => k.Substring("key.".Length)).Select(int.Parse).Select(i => new KeyValuePair("key." + i, new Mocko(i))).ToList(); }); - for (int i=0; i("11")); - Assert.IsNotNull(c1.Get("12")); - Assert.IsNotNull(c1.Get("13")); - Assert.IsNotNull(c1.Get("33")); + Assert.IsNotNull(await c1.GetAsync("11")); + Assert.IsNotNull(await c1.GetAsync("12")); + Assert.IsNotNull(await c1.GetAsync("13")); + Assert.IsNotNull(await c1.GetAsync("33")); Assert.IsNull(c1.Get("22")); Assert.IsNull(c1.Get("23")); Console.WriteLine("Deleting by pattern 1*"); - c2.RemoveByPattern("1*"); + await c2.RemoveByPatternAsync("1*"); await Task.Delay(400); Assert.IsNull(c1.Get("11")); Assert.IsNull(c1.Get("12")); Assert.IsNull(c1.Get("13")); - Assert.IsNotNull(c1.Get("33")); + Assert.IsNotNull(await c1.GetAsync("33")); } [TestMethod] [TestCategory("Integration")] public async Task TestDistributedInvalidationPerformance() { - var iterations = 1000000.0d; + var iterations = 500000.0d; var redis = ConnectionMultiplexer.Connect("localhost"); - var c1 = new RedisBackplaneHzCache(new RedisBackplaneMemoryMemoryCacheOptions + var c1 = new RedisBackedHzCache(new RedisBackedHzCacheOptions { redisConnectionString = "localhost", applicationCachePrefix = "test", @@ -272,7 +336,7 @@ public async Task TestDistributedInvalidationPerformance() useRedisAs2ndLevelCache = true }); - var c2 = new RedisBackplaneHzCache(new RedisBackplaneMemoryMemoryCacheOptions + var c2 = new RedisBackedHzCache(new RedisBackedHzCacheOptions { redisConnectionString = "localhost:6379", applicationCachePrefix = "test", @@ -282,18 +346,18 @@ public async Task TestDistributedInvalidationPerformance() }); Console.WriteLine("Adding 1 to c1"); - c1.Set("1", new Mocko(1)); + await c1.SetAsync("1", new Mocko(1)); await Task.Delay(10); Console.WriteLine("Adding 1 to c2"); - c2.Set("1", new Mocko(1)); + await c2.SetAsync("1", new Mocko(1)); await Task.Delay(20); - Assert.IsNotNull(c1.Get("1")); - Assert.IsNotNull(c2.Get("1")); + Assert.IsNotNull(await c1.GetAsync("1")); + Assert.IsNotNull(await c2.GetAsync("1")); var start = Stopwatch.StartNew(); for (var i = 0; i < iterations; i++) { - c1.Set("test" + i, new Mocko(i)); + await c1.SetAsync("test" + i, new Mocko(i)); } var end = start.ElapsedTicks; @@ -313,6 +377,7 @@ public async Task TestDistributedInvalidationPerformance() Console.WriteLine($"TTA: {setTime / iterations} ms/cache storage operation {setTime} ms/{iterations} items"); Console.WriteLine($"Postprocessing {iterations} items took {postProcessingTime} ms, {postProcessingTime / iterations} ms/item"); Console.WriteLine($"Complete throughput to redis: {iterations / (setTime + postProcessingTime)} items/ms"); + Console.WriteLine(c1.GetStatistics().ToString()); } } } diff --git a/UnitTests/UnitTests.cs b/UnitTests/UnitTests.cs index beca677..47bf11d 100644 --- a/UnitTests/UnitTests.cs +++ b/UnitTests/UnitTests.cs @@ -1,5 +1,5 @@ using System.Diagnostics; -using hzcache; +using HzCache; namespace UnitTests { @@ -305,21 +305,28 @@ public async Task TestRedisBatchGet() { var cache = new HzMemoryCache(new HzCacheOptions {evictionPolicy = EvictionPolicy.FIFO, cleanupJobInterval = 50}); - for (int i = 0; i < 10; i++) + for (var i = 0; i < 10; i++) { - cache.Set("key."+i, new Mocko(i)); + cache.Set("key." + i, new Mocko(i)); } - var keys = new List { "key.0", "key.2", "key.20", "key.30", "key.40"}; + var keys = new List + { + "key.0", + "key.2", + "key.20", + "key.30", + "key.40" + }; var x = cache.GetOrSetBatch(keys, list => { var strKeys = String.Join(",", list); Assert.AreEqual("key.20,key.30,key.40", strKeys); - return list.Select(k => k.Substring("key.".Length)).Select(int.Parse).Select(i => new KeyValuePair("key."+i, new Mocko(i))).ToList(); + return list.Select(k => k.Substring("key.".Length)).Select(int.Parse).Select(i => new KeyValuePair("key." + i, new Mocko(i))).ToList(); }); - for (int i=0; i("key")); } - + [TestMethod] public async Task TestTtlExtended() { diff --git a/UnitTests/UnitTests.csproj b/UnitTests/UnitTests.csproj index 2e3ce6f..1c2c203 100644 --- a/UnitTests/UnitTests.csproj +++ b/UnitTests/UnitTests.csproj @@ -9,6 +9,9 @@ + + + @@ -17,7 +20,7 @@ - + diff --git a/UnitTests/UnitTestsAsync.cs b/UnitTests/UnitTestsAsync.cs new file mode 100644 index 0000000..5aa2484 --- /dev/null +++ b/UnitTests/UnitTestsAsync.cs @@ -0,0 +1,357 @@ +using System.Diagnostics; +using HzCache; + +namespace UnitTests +{ + [TestClass] + public class UnitTestsAsync + { + [TestMethod] + public async Task TestValueChangeNotificationAsync() + { + var addOrUpdates = 0; + var removals = 0; + var expires = 0; + var cache = new HzMemoryCache( + new HzCacheOptions + { + cleanupJobInterval = 50, + notificationType = NotificationType.Sync, + valueChangeListener = (_, changeType, _, _, _) => + { + switch (changeType) + { + case CacheItemChangeType.AddOrUpdate: + addOrUpdates++; + break; + case CacheItemChangeType.Expire: + expires++; + break; + case CacheItemChangeType.Remove: + removals++; + break; + } + } + }); + await cache.SetAsync("mock2", new MockObject(1)); + Assert.AreEqual(1, addOrUpdates); + await cache.SetAsync("mock2", new MockObject(2)); + Assert.AreEqual(2, addOrUpdates); + await cache.RemoveAsync("mock2"); + Assert.AreEqual(1, removals); + await cache.RemoveAsync("mock2"); + Assert.AreEqual(2, removals); + await cache.GetOrSetAsync("m", async _ => Task.FromResult(new MockObject(1)), TimeSpan.FromMilliseconds(100)); + Assert.AreEqual(3, addOrUpdates); + await Task.Delay(200); + Assert.AreEqual(1, expires); + } + + [TestMethod] + public async Task TestRemoveByPatternAsync() + { + var removals = 0; + var cache = new HzMemoryCache( + new HzCacheOptions + { + cleanupJobInterval = 200, + valueChangeListener = async (key, changeType, _, _, _) => + { + switch (changeType) + { + case CacheItemChangeType.Remove: + Console.WriteLine("Removed " + key); + removals++; + break; + } + } + }); + await cache.SetAsync("pelle", new MockObject(42)); + await cache.SetAsync("olle", new MockObject(42)); + await cache.SetAsync("kalle", new MockObject(42)); + await cache.SetAsync("stina", new MockObject(42)); + await cache.SetAsync("lina", new MockObject(42)); + await cache.SetAsync("nina", new MockObject(42)); + await cache.SetAsync("tom", new MockObject(42)); + await cache.SetAsync("tomma", new MockObject(42)); + await cache.SetAsync("flina", new MockObject(42)); + + await cache.RemoveByPatternAsync("tom*"); + Assert.IsNull(cache.Get("tom")); + Assert.IsNull(cache.Get("tomma")); + + await cache.RemoveByPatternAsync("*lle*"); + Assert.IsNull(cache.Get("pelle")); + Assert.IsNull(cache.Get("kalle")); + Assert.IsNull(cache.Get("olle")); + Assert.IsNotNull(cache.Get("stina")); + Assert.IsNotNull(cache.Get("lina")); + Assert.IsNotNull(cache.Get("nina")); + await Task.Delay(100); + Assert.AreEqual(2, removals); // Regex removals are not object by object. + Assert.AreEqual(4, cache.Count); + } + + [TestMethod] + public async Task TestGetSetCleanupAsync() + { + var cache = new HzMemoryCache(new HzCacheOptions {cleanupJobInterval = 200}); + await cache.SetAsync("42", new MockObject(42), TimeSpan.FromMilliseconds(100)); + var v = await cache.GetAsync("42"); + Assert.IsTrue(v != null); + Assert.IsTrue(v.num == 42); + + await Task.Delay(300); + Assert.IsTrue(cache.Count == 0); //cleanup job has run? + } + + [TestMethod] + public async Task TestEvictionAsync() + { + var list = new List(); + for (var i = 0; i < 20; i++) + { + var cache = new HzMemoryCache(new HzCacheOptions {cleanupJobInterval = 200}); + await cache.SetAsync("42", new MockObject(42), TimeSpan.FromMilliseconds(100)); + list.Add(cache); + } + + await Task.Delay(300); + + for (var i = 0; i < 20; i++) + { + Assert.IsTrue(list[i].Count == 0); //cleanup job has run? + } + + //cleanup + for (var i = 0; i < 20; i++) + { + list[i].Dispose(); + } + } + + [TestMethod] + public async Task ShortdelayAsync() + { + var cache = new HzMemoryCache(); + await cache.SetAsync("42", new MockObject(42), TimeSpan.FromMilliseconds(500)); + + await Task.Delay(20); + + var result = await cache.GetAsync("42"); + Assert.IsNotNull(result); //not evicted + Assert.IsTrue(result.num == 42); + } + + [TestMethod] + public async Task TestPrimitivesAsync() + { + var cache = new HzMemoryCache(); + await cache.GetOrSetAsync("42", v => Task.FromResult(42), TimeSpan.FromMilliseconds(500)); + + await Task.Delay(20); + + var result = await cache.GetAsync("42"); + Assert.IsNotNull(result); //not evicted + Assert.IsTrue(result == 42); + } + + [TestMethod] + public async Task TestWithDefaultJobIntervalAsync() + { + var cache2 = new HzMemoryCache(); + await cache2.SetAsync("42", new MockObject(42), TimeSpan.FromMilliseconds(100)); + Assert.IsNotNull(cache2.Get("42")); + await Task.Delay(150); + Assert.IsNull(await cache2.GetAsync("42")); + } + + [TestMethod] + public async Task TestRemoveAsync() + { + var cache = new HzMemoryCache(); + await cache.SetAsync("42", new MockObject(42), TimeSpan.FromMilliseconds(100)); + await cache.RemoveAsync("42"); + Assert.IsNull(cache.Get("42")); + } + + [TestMethod] + public async Task TestTryRemoveAsync() + { + var cache = new HzMemoryCache(); + cache.Set("42", new MockObject(42), TimeSpan.FromMilliseconds(100)); + var res = await cache.RemoveAsync("42"); + Assert.IsTrue(res); + Assert.IsNull(await cache.GetAsync("42")); + + //now try remove non-existing item + Assert.IsFalse(await cache.RemoveAsync("blabblah")); + } + + [TestMethod] + public async Task TestTryRemoveWithTtlAsync() + { + var cache = new HzMemoryCache(); + await cache.SetAsync("42", new MockObject(42), TimeSpan.FromMilliseconds(100)); + await Task.Delay(120); //let the item expire + + var res = await cache.RemoveAsync("42"); + Assert.IsFalse(res); + } + + [TestMethod] + public async Task TestClear() + { + var cache = new HzMemoryCache(); + await cache.GetOrSetAsync("key", _ => Task.FromResult(new MockObject(1024)), TimeSpan.FromSeconds(100)); + + await cache.ClearAsync(); + + Assert.IsNull(cache.Get("key")); + } + + [TestMethod] + public async Task TestStampedeProtectionAsync() + { + var cache = new HzMemoryCache(); + var stopwatch = Stopwatch.StartNew(); + Task.Run(async () => + { + await cache.GetOrSetAsync("key", _ => + { + Task.Delay(2000).GetAwaiter().GetResult(); + return Task.FromResult(new MockObject(1024)); + }, TimeSpan.FromSeconds(100)); + }); + await Task.Delay(50); + var timeToInsert = stopwatch.ElapsedTicks / Stopwatch.Frequency * 1000; + stopwatch.Restart(); + var x = await cache.GetOrSetAsync("key", _ => Task.FromResult(new MockObject(1024)), TimeSpan.FromSeconds(100)); + var timeToWait = stopwatch.ElapsedTicks / Stopwatch.Frequency * 1000; + stopwatch.Restart(); + await cache.GetOrSetAsync("key", _ => Task.FromResult(new MockObject(1024)), TimeSpan.FromSeconds(100)); + + var timeToGetFromMemoryCache = stopwatch.ElapsedTicks / Stopwatch.Frequency * 1000; + + Console.WriteLine($"Insert: {timeToInsert}, Wait: {timeToWait}, Get: {timeToGetFromMemoryCache}"); + + Assert.IsTrue(timeToInsert < 50); + Assert.IsTrue(timeToWait is > 950 and < 1050); + Assert.IsTrue(timeToGetFromMemoryCache < 50); + } + + [TestMethod] + public async Task TestStampedeProtectionTimeoutAsync() + { + var cache = new HzMemoryCache(); + var stopwatch = Stopwatch.StartNew(); + Task.Run(() => + { + cache.GetOrSet("key", _ => + { + Task.Delay(2000).GetAwaiter().GetResult(); + return new MockObject(1024); + }, TimeSpan.FromSeconds(100), 1000); + }); + await Task.Delay(50); + var timeToInsert = stopwatch.ElapsedTicks / Stopwatch.Frequency * 1000; + stopwatch.Restart(); + var x = cache.GetOrSet("key", _ => new MockObject(2048), TimeSpan.FromSeconds(100)); + var timeToWait = stopwatch.ElapsedTicks / Stopwatch.Frequency * 1000; + stopwatch.Restart(); + var value = cache.GetOrSet("key", _ => new MockObject(1024), TimeSpan.FromSeconds(100)); + Assert.AreEqual(value?.num, 2048); + } + + [TestMethod] + public async Task TestNullValueAsync() + { + var cache = new HzMemoryCache(); + await cache.SetAsync("key", null, TimeSpan.FromSeconds(100)); + Assert.IsNull(await cache.GetAsync("key")); + } + + [TestMethod] + public async Task TestLRUPolicyAsync() + { + var cache = new HzMemoryCache(new HzCacheOptions {evictionPolicy = EvictionPolicy.LRU, cleanupJobInterval = 50}); + await cache.SetAsync("key", new MockObject(1), TimeSpan.FromMilliseconds(120)); + Assert.IsNotNull(await cache.GetAsync("key")); + await Task.Delay(100); + Assert.IsNotNull(await cache.GetAsync("key")); + await Task.Delay(100); + Assert.IsNotNull(await cache.GetAsync("key")); + await Task.Delay(125); + Assert.IsNull(await cache.GetAsync("key")); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task TestRedisBatchGetAsync() + { + var cache = new HzMemoryCache(new HzCacheOptions {evictionPolicy = EvictionPolicy.FIFO, cleanupJobInterval = 50}); + + for (var i = 0; i < 10; i++) + { + cache.Set("key." + i, new Mocko(i)); + } + + var keys = new List + { + "key.0", + "key.2", + "key.20", + "key.30", + "key.40" + }; + + var x = await cache.GetOrSetBatchAsync(keys, async list => + { + var strKeys = String.Join(",", list); + Assert.AreEqual("key.20,key.30,key.40", strKeys); + return list.Select(k => k.Substring("key.".Length)).Select(int.Parse).Select(i => new KeyValuePair("key." + i, new Mocko(i))).ToList(); + }); + + for (var i = 0; i < keys.Count; i++) + { + var key = keys[i]; + var num = int.Parse(key.Substring("key.".Length)); + Assert.AreEqual(num, x[i].num); + } + } + + + [TestMethod] + public async Task TestFIFOPolicyAsync() + { + var cache = new HzMemoryCache(new HzCacheOptions {evictionPolicy = EvictionPolicy.FIFO, cleanupJobInterval = 50}); + await cache.SetAsync("key", new MockObject(1), TimeSpan.FromMilliseconds(220)); + await Task.Delay(100); + Assert.IsNotNull(await cache.GetAsync("key")); + await Task.Delay(100); + Assert.IsNotNull(await cache.GetAsync("key")); + await Task.Delay(100); + Assert.IsNull(await cache.GetAsync("key")); + } + + [TestMethod] + public async Task TestTtlExtended() + { + var cache = new HzMemoryCache(); + await cache.SetAsync("42", new MockObject(42), TimeSpan.FromMilliseconds(300)); + + await Task.Delay(50); + var result = await cache.GetAsync("42"); + Assert.IsNotNull(result); //not evicted + Assert.IsTrue(result.num == 42); + + await cache.SetAsync("42", new MockObject(42), TimeSpan.FromMilliseconds(300)); + + await Task.Delay(250); + + result = await cache.GetAsync("42"); + Assert.IsNotNull(result); //still not evicted + Assert.IsTrue(result.num == 42); + } + } +} diff --git a/hzcache.sln b/hzcache.sln index 72ad1ca..40e93b3 100644 --- a/hzcache.sln +++ b/hzcache.sln @@ -9,7 +9,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "UnitTests\Unit EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HzCache.Benchmarks", "HzCache.Benchmarks\HzCache.Benchmarks.csproj", "{DD603B8C-5216-4079-B6B2-5AA54ED1B1F3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RedisBackplaneHzCache", "RedisBackplaneHzCache\RedisBackplaneHzCache.csproj", "{21F6B22C-EE16-47F4-A4E3-E10D4F2060D8}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RedisBackedHzCache", "RedisBackedHzCache\RedisBackedHzCache.csproj", "{21F6B22C-EE16-47F4-A4E3-E10D4F2060D8}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RedisIDistributedCache", "RedisIDistributedCache\RedisIDistributedCache.csproj", "{F91CB554-BF50-49BB-898E-809DAF3E3382}" EndProject