Stefan Holm Olsen

Caching custom data that depends on Episerver content

I always consider the performance of my code when taking on development tasks. I know many developers believe that performance should be added when the customer asks them to. Contrary to that, I believe it should always be thought into any solution from the start.

When building Episerver solutions we quite often end up incorporating data from outside the universe of Episerver objects. We might integrate and load from external systems, like a CRM system, a ticket booking system, a billing system, a support desk system, a data warehouse or some special bespoke system.

If we load data from those systems, we would probably want to cache that in our website, so that the Episerver solution does not reload data from those systems on each API or page request.

Using Episerver’s cache provider

Generally, Episerver maintains a cache provider on its own. In short, it is an in-process cache (one on each server) with a synchronization layer on top, which handles deletions of data across a load balanced environment. Episerver has a good and comprehensive documentation page about this.

In short: never EVER use the ASP.Net runtime cache directly! Always use Episerver’s caching interfaces because they will take care of clearing caches on a cluster of servers.

Quan Mai, from Episerver, also wrote an excellent blog post about caching objects in Episerver. In short, he suggests we should primarily use Episerver’s ReadThrough extensions when reading data from the object cache.

With this method we do not have to think about obtaining locks (neither shared, upgradable or exclusive) when reading or writing data from or to cache, nor do we have to check whether data was written while waiting for the lock.

We can choose to make other threads wait for one thread to get and cache data, or we can let all threads have a try and cache only the first of the results.

Performance wins again!

But how to efficiently cache data with dependencies to other cache entries?

Cache keys

Every object that goes into the cache needs a unique name; a cache key. This key has to be unique per object, so that it never gets overwritten by another object by accident.

A cache key for a customer object with ID 12345, could be: "SampleSite:Customers:12345". Whenever we want to read this customer object, we simply recreate the cache key and request the object from the cache provider (as shown before). Simple as that.

Now, imagine that we also have customer related objects from other systems. They need to be cached on their own, but we want to invalidate all of them when the main customer object gets invalidated (either deleted or expired).

In this case, we could give them separate cache keys like these:

  • SampleSite:Customers:12345
  • SampleSite:Customers:12345:SupportCases
  • SampleSite:Customers:12345:NewsletterSubscriptions

And when adding the second and third objects to the cache, we need to supply the first cache key as a dependency cache key.

In code, it could look like this:

private ICollection<SupportCase> GetCustomerSupportCases(int customerId)
{
    // I prefer to generate cache keys in static helper classes (per feature).
    // Magic strings are use here for brevity.
    string cacheKey = "SampleSite:Customers:SupportCases:" + customerId;
 
    ICollection<SupportCase> supportCases = _cache.ReadThrough(
        cacheKey,
        () => _supportSystem.GetCasesByCustomer(customerId)
        cep =>
        {
            string[] dependentKeys =
            {
                "SampleSite:Customers:" + customerId
            };
 
            return new CacheEvictionPolicy(
                TimeSpan.FromMinutes(60),
                CacheTimeoutType.Sliding,
                dependentKeys);
        });
 
    return supportCases;
}

If we later tell the cache provider to remove the first cache key (SampleSite:Customers:12345) from the cache, all three cached entries is actually removed. The code could look like this:

private void InvalidateCustomerData(int customerId)
{
    string cacheKey = "SampleSite:Customers:" + customerId;
    _cache.Remove(cacheKey);
}

You see? We are building a chain here, so we do not need to specifically remove each customer related object.

A nice touch is that, cache entries can depend on cache entries, which themselves depend on other entries. The Episerver cache provider will then follow the chain and cascade the removals.

Master keys

Besides making cache entries depend on specific cache keys, entries can also depend on master keys.

In contrast to cache keys, a master key is not the name of an actual cache entry. It is simply a key that can be depended on. But we can "remove" it from cache, just like any other cache key. Whenever that happens, all objects that depend on it will be removed at once. And objects that depends on those removed objects gets removed from cache, too (because of the cascade effect mentioned before).

Episerver makes heavy use of this concept. For instance, if you remove a language binding from a website, then all pages that were cached in that language gets removed from the cache. All because of a single master key.

Continuing the customer sample from before, we could add a master key for all customer objects. It could be "SampleSite:Customers:*". To force a refresh of all loaded customer objects, we can simply "remove" that master key from cache.

It could also be useful for currency exchange rates, which we update twice a day. Those should definitely be cached for a long time (e.g. 24 hours). But the second we update them in the database, they should all be invalidated from the cache at once. For this a custom master key is perfect. Such code could look like this:

private ExchangeRate GetLatestExchangeRate(string fromCurrency, string toCurrency)
{
    string cacheKey = "SampleSite:ExchangeRates:" + fromCurrency + "-" + toCurrency;
 
    ExchangeRate exchangeRate = _cache.ReadThrough(
        cacheKey,
        () => _exchangeRatesRepository.GetLatestExchangeRate(fromCurrency, toCurrency)
        cep =>
        {
            return new CacheEvictionPolicy(
                TimeSpan.FromHours(24),
                CacheTimeoutType.Absolute,
                Enumerable.Empty<string>(),
                new[] { "SampleSite:ExchangeRates:*" });
        });
 
    return exchangeRate;
}

private void InvalidateAllExchangeRates()
{
    string cacheKey = "SampleSite:ExchangeRates:*";
    _cache.Remove(cacheKey);
}

Depending on Episerver content and settings

Sometimes we may cache custom objects that use or derive from properties of cached Episerver objects. Of course, we should then seek to make our cache entries depend on the Episerver data.

Here is a short list of some of the cache keys and master keys I use the most.

CMS content

To depend on a page, block or asset, use one of the methods in the IContentCacheKeyCreator. In my opinion these are the most important cache keys helper methods.

// Depending on this master key, the entry is removed when the structure of the referenced content is changed (e.g. moved, removed or updated in any language).
string masterKey = _contentCacheKeyCreator.CreateCommonCacheKey(contentReference);
 
// Depending on this cache key, the entry is removed along with the referenced content. 
string cacheKey = _contentCacheKeyCreator.CreateLanguageCacheKey(contentReference, languageCode);

The second method may often be more useful than the first. If a site is multi-lingual and we want to cache an object that contains texts from an Episerver page, then we may want to cache an object per language. The object will be cached with a key that contains the language code, and it should have dependency on the content item in the specific language (using the CreateLanguageCacheKey method).

If we use CreateCommonCacheKey instead, we will invalidate all cached versions, whenever an editor updates the content in a single language.

Site configuration

For website configuration (hostname and language mappings) there are no public helper methods for cache key creation. However, looking at cached keys at run-time, there is a single cache key to use.

// Depending on this cache key, the entry is removed when any mapped site is added or changed.
string cacheKey = "EPi:SiteDefinitionRepo"

This key could be relevant to depend on when, for instance, a cached object holds absolute URLs, that depends on hostnames in a site definition.

Commerce content

Although CMS and Commerce content all derive from the same IContent interface, they are persisted in different ways. The cache keys and master keys are also different from the CMS keys.

// Depending on this master key, the entry is removed with the catalog entry.
int objectId = _referenceConverter.GetObjectId(contentReference);
string masterKey = MasterCacheKeys.GetEntryKey(objectId);

// Depending on this master key, the entry is removed with the catalog node.
int objectId = _referenceConverter.GetObjectId(contentReference);
string masterKey = MasterCacheKeys.GetNodeKey(objectId);

// Depending on this master key, the entry is removed with the specified tax category.
string masterKey = MasterCacheKeys.GetTaxCategoryKey(taxCategoryId);

// Depending on this master key, the entry is removed when any tax is added, updated or removed.
string masterKey = MasterCacheKeys.AnyTaxCategoryKey;

There are many more cache and master keys to depend on. To find them, look through the reference documentation or inspect and derive patterns from the cache at run-time (using the Local Object Cache view in the DeveloperTools add-on).

BUT! do note that some of the built-in cache entries can be quite short-lived (for instance market definitions), while others are long-lived (like content items and site definitions). If you depend on something short-lived, your cache entry will be just as short-lived, no matter which eviction policy you choose.