ResultsetCache Redis

Posts   
 
    
Posts: 9
Joined: 11-Aug-2017
# Posted on: 29-Apr-2021 22:32:30   

I am working on a complex project that is using .Net 4.7.2, LLBLGen 5.4.4 and SQL Server 2016.

The project consists of a web application, windows services, web api, MVC web site, etc. so that is why Redis would be ideal, the distributed cache.

We have already implemented the Microsoft Azure Redis Cache for Session and Output caching (which uses StackExchange.Redis) .We are busy trying to add in caching and I've been looking into using Azure Redis Cache to cache a few resultsets. The resultsets are just rows of config data, about 200 rows, that are used frequently throughout those several applications and can/should be shared. That data does not change often at all.

I tried out the ResultsetCache() from https://www.llblgen.com/documentation/5.4/LLBLGen%20Pro%20RTF/Using%20the%20generated%20code/gencode_resultsetcaching.htm and it seemed to work nicely.

public void Configuration(IAppBuilder app)
{
    CacheController.RegisterCache("", new ResultsetCache());
}

I also found https://github.com/SolutionsDesign/LLBLGenProContrib/blob/master/SD.LLBLGen.Pro.ORMSupportClasses.Contrib/Caching/MemoryCachedResultsetCache.cs

I did try and copy the MemoryCachedResultsetCache and use a StackExchange.Redis implementation but I got stuck with the CacheKey and RedisKey differences in all the methods.

I have searched the internet for a Redis ResultsetCache implementation but I can't find anything. Does anyone have any pointers or links that could help out? It would be greatly appreciated.

Walaa avatar
Walaa
Support Team
Posts: 14946
Joined: 21-Aug-2005
# Posted on: 30-Apr-2021 05:59:11   

I think you need to read this part of the documentation: CacheKey and 3rd party caches

Posts: 9
Joined: 11-Aug-2017
# Posted on: 30-Apr-2021 12:43:26   

Walaa wrote:

I think you need to read this part of the documentation: CacheKey and 3rd party caches

Thanks.

My selective scan reading missed that vital part.

Posts: 9
Joined: 11-Aug-2017
# Posted on: 30-Apr-2021 13:05:06   

I copied Frans Bouma's LLBLGenProContrib and made a few changes to it:

  • Changed MemoryCache to StackExchange.Redis support

  • Changed the _cacheKeyToMemoryCacheKey to use a string instead of Guid. This is so that I can append a prefix name for each tenant, in a multi-tenanted server deployment.

It seems to be working in the web application.

    /// <summary>
    /// Class which keeps track of cachekeys and creates memorycache keys. It purges keys if necessary on time. 
    /// </summary>
    public class CacheKeyStore
    {
        private readonly Dictionary<CacheKey, string> _cacheKeyToMemoryCacheKey;
        private readonly MultiValueHashtable<string, CacheKey> _cacheKeysPerTag;
        private readonly List<ValuePair<CacheKey, DateTime>> _cacheKeysAndExpireDates;
        private readonly object _semaphore;
        private readonly Timer _purgeTimer;
        private readonly string _prefix;

        /// <summary>
        /// Initializes a new instance of the <see cref="CacheKeyStore"/> class.
        /// </summary>
        public CacheKeyStore()
        {
            _prefix = ConfigurationManager.AppSettings["Tenant.Name"]?.Replace(' ', '_');
            _cacheKeysPerTag = new MultiValueHashtable<string, CacheKey>();
            _cacheKeyToMemoryCacheKey = new Dictionary<CacheKey, string>();
            _cacheKeysAndExpireDates = new List<ValuePair<CacheKey, DateTime>>();
            _semaphore = new object();
            _purgeTimer = new Timer(5.0);
            _purgeTimer.Elapsed += _purgeTimer_Elapsed;
            _purgeTimer.Enabled = true;
        }

        /// <summary>
        /// Gets a memorycache usable key for the original cachekey specified which is stored in this store, if it doesn't exist already, otherwise the
        /// existing one is returned.
        /// </summary>
        /// <param name="originalKey">The original key.</param>
        /// <param name="duration">The duration. Should not be Zero.</param>
        /// <param name="tag">The tag.</param>
        /// <returns></returns>
        public string GetPersistentCacheKey(CacheKey originalKey, TimeSpan duration, string tag)
        {
            return GetMemoryCacheKey(originalKey, true, duration, tag);
        }

        /// <summary>
        /// Gets a non-persistent key usable for memorycache for the specified original key. The key isn't stored in this store, however if a key is found for
        /// the specified original cachekey in this store, that key is returned.
        /// </summary>
        /// <param name="originalKey">The original key.</param>
        /// <returns></returns>
        public string GetNonPersistentCacheKey(CacheKey originalKey)
        {
            return GetMemoryCacheKey(originalKey, false, TimeSpan.Zero, string.Empty);
        }

        /// <summary>
        /// Gets the cache keys stored in this store which are associated with the tag specified, if not empty.
        /// </summary>
        /// <param name="tag">The tag.</param>
        /// <returns></returns>
        public List<CacheKey> GetCacheKeysForTag(string tag)
        {
            var toReturn = new List<CacheKey>();
            using (TimedLock.Lock(_semaphore))
            {
                if (!string.IsNullOrEmpty(tag) && _cacheKeysPerTag.ContainsKey(tag))
                {
                    toReturn.AddRange(_cacheKeysPerTag.GetValue(tag));
                }
            }
            return toReturn;
        }

        /// <summary>
        /// Gets the memory cache key to use for the cachekey specified.
        /// </summary>
        /// <param name="originalKey">The original key.</param>
        /// <param name="keepMemoryCacheKey">if set to <c>true</c> it will keep the key in the storage</param>
        /// <param name="duration">The duration, if available, the cachekey is valid. Only used if keepMemoryCacheKey is true. If TimeSpan.Zero, it's considered
        /// not available/specified</param>
        /// <param name="tag">The tag, if available. Only used if keepMemoryCacheKey is true.</param>
        /// <returns>
        /// the string equivalent of the guid associated with the cachekey specified
        /// </returns>
        private string GetMemoryCacheKey(CacheKey originalKey, bool keepMemoryCacheKey, TimeSpan duration, string tag)
        {
            string toReturn = string.Empty;
            using (TimedLock.Lock(_semaphore))
            {
                if (!_cacheKeyToMemoryCacheKey.TryGetValue(originalKey, out toReturn))
                {
                    toReturn = $"{_prefix}_{Guid.NewGuid()}";
                    if (keepMemoryCacheKey)
                    {
                        _cacheKeyToMemoryCacheKey.Add(originalKey, toReturn);
                        if (!string.IsNullOrEmpty(tag))
                        {
                            _cacheKeysPerTag.Add(tag, originalKey);
                        }
                        if (duration != TimeSpan.Zero)
                        {
                            _cacheKeysAndExpireDates.Add(new ValuePair<CacheKey, DateTime>(originalKey, DateTime.Now.ToUniversalTime().Add(duration)));
                        }
                    }
                }
            }
            return toReturn;
        }

        /// <summary>
        /// Purges the expired cachekeys from the store. 
        /// </summary>
        private void PurgeExpiredElements()
        {
            using (TimedLock.Lock(_semaphore))
            {
                var now = DateTime.Now.ToUniversalTime();
                var toPurge = _cacheKeysAndExpireDates.Where(kvp => kvp.Value2 < now).ToList();

                foreach (var key in toPurge)
                {
                    _cacheKeysAndExpireDates.Remove(key);
                    _cacheKeyToMemoryCacheKey.Remove(key.Value1);
                    var tagsToRemove = new List<string>();
                    foreach (var kvp in _cacheKeysPerTag)
                    {
                        if (kvp.Value.Contains(key.Value1))
                        {
                            ((ICollection<CacheKey>)kvp.Value).Remove(key.Value1);
                            if (kvp.Value.Count <= 0)
                            {
                                tagsToRemove.Add(kvp.Key);
                            }
                        }
                    }
                    foreach (var tag in tagsToRemove)
                    {
                        _cacheKeysPerTag.Remove(tag);
                    }
                }
            }
        }

        /// <summary>
        /// Handles the Elapsed event of the _purgeTimer control.
        /// </summary>
        /// <param name="sender">The source of the event.</param>
        /// <param name="e">The <see cref="ElapsedEventArgs" /> instance containing the event data.</param>
        void _purgeTimer_Elapsed(object sender, ElapsedEventArgs e)
        {
            PurgeExpiredElements();
        }
    }
    public class RedisResultsetCache : IResultsetCache
    {
        private static readonly Lazy<ConnectionMultiplexer> LazyConnection = new Lazy<ConnectionMultiplexer>(() =>
        {
            return ConnectionMultiplexer.Connect(ConfigurationManager.ConnectionStrings["Redis.ConnectionString"].ConnectionString);
        });

        public static ConnectionMultiplexer Connection => LazyConnection.Value;

        public static IDatabase Database => Connection.GetDatabase();

        private readonly CacheKeyStore _keyStore;

        public RedisResultsetCache()
        {
            _keyStore = new CacheKeyStore();
        }

        /// <summary>
        /// Adds the specified toCache to this cache under the key specified for the duration specified
        /// </summary>
        /// <param name="key">The key.</param>
        /// <param name="toCache">The resultset to cache</param>
        /// <param name="duration">The duration how long the resultset will stay in the cache.</param>
        /// <remarks>
        /// If an object is already present under 'key', Add is a no-op.
        /// </remarks>
        public void Add(CacheKey key, CachedResultset toCache, TimeSpan duration)
        {
            Add(key, toCache, duration, false, string.Empty);
        }

        /// <summary>
        /// Adds the specified toCache to this cache under the key specified for the duration specified
        /// </summary>
        /// <param name="key">The key.</param>
        /// <param name="toCache">The resultset to cache</param>
        /// <param name="duration">The duration how long the resultset will stay in the cache.</param>
        /// <param name="overwriteIfPresent">if set to <c>true</c> it will replace an existing cached set with the one specified.</param>
        public void Add(CacheKey key, CachedResultset toCache, TimeSpan duration, bool overwriteIfPresent)
        {
            Add(key, toCache, duration, overwriteIfPresent, string.Empty);
        }

        /// <summary>
        /// Adds the specified toCache to this cache under the key specified for the duration specified and assigns the tag specified to it.
        /// </summary>
        /// <param name="key">The key.</param>
        /// <param name="toCache">The resultset to cache</param>
        /// <param name="duration">The duration how long the resultset will stay in the cache.</param>
        /// <param name="overwriteIfPresent">if set to <c>true</c> it will replace an existing cached set with the one specified.</param>
        /// <param name="tag">The tag under which the resultset has to be cached.</param>
        public void Add(CacheKey key, CachedResultset toCache, TimeSpan duration, bool overwriteIfPresent, string tag)
        {
            var keyToUse = _keyStore.GetPersistentCacheKey(key, duration, tag);

            Database.StringSet(keyToUse, JsonConvert.SerializeObject(toCache), duration);
        }

        /// <summary>
        /// Gets the cached resultset under the key specified.
        /// </summary>
        /// <param name="key">The key.</param>
        /// <returns>
        /// The cached resultset, if present, otherwise null
        /// </returns>
        public CachedResultset Get(CacheKey key)
        {
            var redisValue = Database.StringGet(_keyStore.GetNonPersistentCacheKey(key));

            if (redisValue == RedisValue.Null)
                return null;

            return JsonConvert.DeserializeObject<CachedResultset>(redisValue);
        }

        /// <summary>
        /// Gets the cached resultsets with the tag specified.
        /// </summary>
        /// <param name="tag">The tag which resultsets have to be returned.</param>
        /// <returns>
        /// the cached resultsets which have the specified tag assigned to them, otherwise empty list.
        /// </returns>
        public List<CachedResultset> Get(string tag)
        {
            var toReturn = new List<CachedResultset>();
            var cacheKeys = _keyStore.GetCacheKeysForTag(tag);
            foreach (var cacheKey in cacheKeys)
            {
                var toAdd = Get(cacheKey);
                if (toAdd != null)
                {
                    toReturn.Add(toAdd);
                }
            }
            return toReturn;
        }

        /// <summary>
        /// Purges the resultset cached under the key specified from the cache, if present.
        /// </summary>
        /// <param name="key">The key.</param>
        public void PurgeResultset(CacheKey key)
        {
            Database.KeyDelete(_keyStore.GetNonPersistentCacheKey(key));
        }

        /// <summary>
        /// Purges the resultsets cached with the tag specified from the cache, if present.
        /// </summary>
        /// <param name="tag">The tag.</param>
        public void PurgeResultsets(string tag)
        {
            var cacheKeys = _keyStore.GetCacheKeysForTag(tag);
            foreach (var key in cacheKeys)
            {
                PurgeResultset(key);
            }
        }
    }

That's as far as I've gotten so far.

Otis avatar
Otis
LLBLGen Pro Team
Posts: 39590
Joined: 17-Aug-2003
# Posted on: 30-Apr-2021 13:22:37   

Looks ok to me, the only thing you might want to keep an eye on is the timer interval. It's currently set to 5 seconds which thus purges keys not used anymore every 5 seconds but if you have a very high turn over it might mean your keyset is pretty big and you might want to change it to a lower value.

Thanks for sharing! simple_smile

Frans Bouma | Lead developer LLBLGen Pro
Posts: 9
Joined: 11-Aug-2017
# Posted on: 30-Apr-2021 18:43:58   

Otis wrote:

Looks ok to me, the only thing you might want to keep an eye on is the timer interval. It's currently set to 5 seconds which thus purges keys not used anymore every 5 seconds but if you have a very high turn over it might mean your keyset is pretty big and you might want to change it to a lower value.

Thanks for sharing! simple_smile

Thanks for the info.

It should be fine at 5 seconds because it is not a lot of data at the moment. It's about 2 resultsets per tenant and about 50 rows with 2 x 50 character columns per resultset. The cache duration is currently set at 300 seconds and is purged if changes are made to any of those tables.

Otis avatar
Otis
LLBLGen Pro Team
Posts: 39590
Joined: 17-Aug-2003
# Posted on: 01-May-2021 09:25:41   

then it should be fine simple_smile

Frans Bouma | Lead developer LLBLGen Pro