1
\$\begingroup\$

IReadOnlyCachedRepository<T>

public interface IReadOnlyCachedRepository<T>
{
    void RefreshIfExpired();
    void RefreshSubset(Expression<Func<T, bool>> selector);
    IEnumerable<T> All();
    T Get(Expression<Func<T, bool>> selector);
    bool Exists(Expression<Func<T, bool>> selector);
    int Count(Expression<Func<T, bool>> selector);
}

ReadOnlyCachedRepository<T>

public class ReadOnlyCachedRepository<T> : IReadOnlyCachedRepository<T> where T : class
{
    protected static readonly TimeSpan DefaultCacheRefreshInterval = TimeSpan.FromMinutes(15);

    protected static ConcurrentDictionary<T, bool> CachedData;
    protected static DateTime EntireCacheLastUpdatedOn = DateTime.MinValue;
    protected static DateTime NextCacheRefreshOn = DateTime.MinValue;

    protected static ILogger Logger;
    protected DbContext Db;
    protected TimeSpan CacheRefreshInterval;

    public ReadOnlyCachedRepository(DbContext db)
    {
        Logger = new Log4NetWrapper(this.GetType());

        Db = db;
        CacheRefreshInterval = DefaultCacheRefreshInterval;
    }

    /// <summary>
    /// Determines the entire set of data to cache.
    /// Override this to change the scope of the cache.
    /// </summary>
    protected virtual IEnumerable<T> GetDataToCache(Expression<Func<T, bool>> selector = null)
    {
        if (selector == null)
        {
            return Db.Set<T>().AsNoTracking<T>().ToList();
        }
        else
        {
            return Db.Set<T>().AsNoTracking<T>().Where(selector.Compile()).ToList();
        }
    }

    /// <summary>
    /// Refreshes the entire cached data set if it has expired.
    /// </summary>
    public void RefreshIfExpired()
    {
        if (DateTime.Now >= NextCacheRefreshOn)
        {
            var dataToCache = GetDataToCache().ToDictionary(
                keySelector: x => x,
                elementSelector: x => false
            );
            CachedData = new ConcurrentDictionary<T, bool>(dataToCache);
            EntireCacheLastUpdatedOn = DateTime.Now;
            NextCacheRefreshOn = EntireCacheLastUpdatedOn.Add(CacheRefreshInterval);
        }
    }

    /// <summary>
    /// Removes any cached data matching the selector and pulls data to be cached matching the selector.
    /// </summary>
    public void RefreshSubset(Expression<Func<T, bool>> selector)
    {
        var dataToRemove = CachedData.Select(x => x.Key).Where(selector.Compile());
        foreach (var data in dataToRemove)
        {
            CachedData.TryRemove(data, out _);
        }

        var dataFailedToRefresh = new List<T>();
        var dataToRefresh = GetDataToCache(selector);
        foreach (var data in dataToRefresh)
        {
            var wasAdded = CachedData.TryAdd(data, false);
            if (wasAdded == false)
            {
                dataFailedToRefresh.Add(data);
            }
        }

        Logger.Log(Level.Debug, $"{dataToRemove.Count()} items were removed from the cache. {dataFailedToRefresh.Count} of {dataToRefresh.Count()} items of the cache subset failed to be added to the cache.");
    }

    /// <summary>
    /// Retrieves all cached data.
    /// </summary>
    public virtual IEnumerable<T> All()
    {
        RefreshIfExpired();
        return CachedData.Keys;
    }

    /// <summary>
    /// Retrieves the first item matching the selector in the cache or null.
    /// </summary>
    public virtual T Get(Expression<Func<T, bool>> selector)
    {
        RefreshIfExpired();
        return CachedData.Keys.FirstOrDefault(selector.Compile());
    }

    /// <summary>
    /// Checks if at least one item matching the selector exists in the cache.
    /// </summary>
    public virtual bool Exists(Expression<Func<T, bool>> selector)
    {
        RefreshIfExpired();
        return CachedData.Keys.Any(selector.Compile());
    }

    /// <summary>
    /// Retrieves the number items matching the selector in the cache.
    /// </summary>
    public virtual int Count(Expression<Func<T, bool>> selector)
    {
        RefreshIfExpired();
        return CachedData.Keys.Count(selector.Compile());
    }
}

CustomerCachedRepository

An example of an override of ReadOnlyCachedRepository<T>.GetDataToCache(..).

public class CustomerCachedRepository : ReadOnlyCachedRepository<Customer>
{
    public CustomerCachedRepository(DbContext db) : base(db)
    {
        CacheRefreshInterval = TimeSpan.FromHours(16);
    }

    protected override IEnumerable<Customer> GetDataToCache(Expression<Func<Customer, bool>> selector = null)
    {
        var query = Db.Set<Customer>()
                     .AsNoTracking<Customer>()
                     .Where(x => x.IsBlocked == false);

        if (selector != null)
        {
            query = query.Where(selector);
        }

        return query.ToList();
    }
}

Concerns

  • Is there a better choice than ConcurrentDictionary<T, bool>? I started with ConcurrentBag<T> but it didn't have the ability to add/remove specific items from the cache. I then switched to dictionary since it had the ability to add/remove items but I'm not using the value (aka the bool). Which feels bad/hackish.
  • Thread-Safety - The whole reason I am using concurrent collections is because I'm not very knowledgeable in the area of thread-safety and I just want to make sure that in an MVC environment where each controller instance is going to have access to this repository that it doesn't try to refresh the repository twice at the same time.
  • Performance - I wanted to cache the data to reduce the number of connections between the application server and database, which isn't necessarily going to increase performance but I would like to definitely have performance as optimal as possible with this implementation.

Purpose/Usage

  • RefreshIfExpired() only refreshes the cache if it has expired. Allowing this to be called on every repository action (and in administrator related actions if necessary; which is why it's exposed).
  • RefreshSubset() forces a refresh but only to the subset of data selected. It does not update the expired timestamp since it is only a subset of the data, so there is a chance that it'll expire immediately after it is refreshed (taking that minor risk in favor of simpler implementation). This is also exposed so that the actions that update user permissions can call it for a particular username to always keep permissions cache up to date.
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Another alternative to you design would be the decorator pattern. Instead of inheriting the CustomerCachedRepository from the ReadOnlyCachedRepository you would decorate it with it. This means composition over inheritance.

The advantage of this kind of design would be easier testing because it's easier to create a mock class for IReadOnlyCachedRepository than to derive it from the base class.

But most importantly you could test them independently and exchange the cache anytime you want without much effort.

It's not always the best pattern but here I think, I would work better.


The API of this repository have some methods that I find it shouldn't have.: RefreshIfExpired and RefreshSubset. I cannot check whether anything is expired so I'm not sure how they are supposed to be used. The repository should just have APIs that are directrly related only to the model they are working with and if anything is expired, then the repository should handle it internally by itself and without involing the user in this process.

\$\endgroup\$
2
  • \$\begingroup\$ Sorry, I updated the purpose/usage section of my question so that my intentions are a bit more clear in regards to the refresh functions. \$\endgroup\$
    – Shelby115
    Commented Jun 15, 2018 at 17:38
  • \$\begingroup\$ Long story short, they're exposed for administrative purposes and for when the application knows that it has invalidated the cache. \$\endgroup\$
    – Shelby115
    Commented Jun 15, 2018 at 17:43

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.