Time-Based Caching of a Sitecore Rendering
Tags: sitecore, cache
Published: 2020-09-16 23:12:05

### **The Problem**
I ran into a scenario with a client reskinning their site, and the biggest change was converting the header from a simple click-and-go to a full megamenu. In the megamenu, each section brought in a chunk of the landing page functionality, from a search query to a multi-level drilldown. This means making a lot of data calls to assemble the viewmodel, and then spitting this out. Given all that, it certainly makes sense to cache.

Since Sitecore gives us a nice, easy way to cache a rendering (which is an HTML cache), it makes sense to take advantage of that. The catch to remember is that when a publish happens, the cache is invalidated. Which means that the header will get decached frequently if there's a lot of publishing going on. Not the best scenario, even if you build a good back-end data model!

### **The Research**
Digging through Google for help on this, I came across [https://sitecoremaster.com/caching/customizing-html-caching-with-sitecore-with-mvc/](one article that spoke about adding a timeout field) that would supplant the Sitecore default timeout (which is 00:00, IE forever). And this did work...I set it up, plugged in 5 minutes for the timeout, and the cache did invalidate every 5 minutes. Except if I published, it also still invalidated.

I asked around the good ol' Sitecore Slack to see if there was a way to exclude a rendering from being decached on publish, and out of the box there's no way to do it. I tried digging into the methods that clear the cache, nothing good there. Then I looked into the caching methods themselves, the actual getter and setter, and I found the secret sauce.

### **The Solution**
![Secret Sauce!](https://dev-to-uploads.s3.amazonaws.com/i/pqvy9pfneqkbdaa6gkch.jpg)

Inside the getter (`RenderFromCache`) and the setter (`AddRecordedHtmlToCache`), by default it uses the current site context to save the cache. But these methods are all virtual, and thus all changeable by a developer. So then I had an idea...what if, instead of saving the cache to the current site, what if I saved it to another site...one that wasn't subject to decaching on publish? So let's get started down this yellow brick road!

####Step 1: Create a site definition
You don't need a full-on site with items and such for this, just a simple site definition. In fact, you can inherit your existing site definition if you want, something like this:
```
<site name="cachingSite" inherits="website" hostName="cachingSite" cacheHtml="true" htmlCacheSize="300MB" allowDebug="false" enablePreview="false" enableWebEdit="false" enableDebugger="false" preventHtmlCacheClear="true" patch:after="site[@name='website']" />
```

The key elements here are the `cacheHtml` setting being true, and the `preventHtmlCacheClear` setting also being true. (Note the `preventHtmlCacheClear` is new for 9.3+; before this, you had to explicitly state the site needed to be cache-cleared on publish. Since we don't want that at all, this covers both scenarios.) I set the `hostName` value just to give it a name that will never be activated, so there's not a "real" site here.

#### Step 2: Settings
I maintain three settings for this:
* `TimeoutRenderingParam` - the ID of the cache timeout field
* `TimeoutRenderingKey` - the name of the key to add to the cache
* `CachingSite` - The site name for the caching site (from above)

This allows the code to be flexible, so that any rendering that uses the Timeout field will use the caching site automatically.

```
using Sitecore.Data;

namespace Foundation.Caching {
    public struct Settings {
        public static ID TimeoutRenderingParam => new ID("{38A43E35-17D1-4D45-B399-9EA66C94B861}");
        public static string TimeoutRenderingKey => "timeout";
        public static string CachingSite => "cachingSite";
    }
}
```

#### Step 3: GenerateCacheKey
This overrides the default Sitecore implementation to add in a cache key value if the timeout field is filled in.

```
using Sitecore.Data.Items;
using Sitecore.Mvc.Pipelines.Response.RenderRendering;
using Sitecore.Mvc.Presentation;

namespace Foundation.Caching.Pipelines.Renderings {
    public class GenerateCacheKey : Sitecore.Mvc.Pipelines.Response.RenderRendering.GenerateCacheKey {
        public GenerateCacheKey(RendererCache rendererCache) : base(rendererCache) {
        }

        protected override string GenerateKey(Rendering rendering, RenderRenderingArgs args) {
            string cacheKey = base.GenerateKey(rendering, args);
            Item renderingItem = rendering.RenderingItem.InnerItem;

            //If the timeout rendering parameter exists and has a value, add a token to the cache key that it's present
            if (!string.IsNullOrEmpty(renderingItem[Settings.TimeoutRenderingParam])) {
                cacheKey += $"_#{Settings.TimeoutRenderingKey}:1";
            }

            return cacheKey;
        }
    }
}
```

#### Step 4: AddRecordedHtmlToCache
This is the "setter" code. If the timeout cache key is present, the code will start to use our caching site to store the HTML, instead of the context site.

```
using System;
using Sitecore.Caching;
using Sitecore.Diagnostics;
using Sitecore.Mvc.Pipelines.Response.RenderRendering;
using Sitecore.Sites;

namespace Foundation.Caching.Pipelines.Renderings {
    public class AddRecordedHtmlToCache : Sitecore.Mvc.Pipelines.Response.RenderRendering.AddRecordedHtmlToCache {
        public override void Process(RenderRenderingArgs args) {
            Assert.ArgumentNotNull(args, nameof(args));
            if (!args.Rendered) { return; }
            string cacheKey = args.CacheKey;
            if (!args.Cacheable || string.IsNullOrEmpty(cacheKey)) { return; }
            UpdateCache(cacheKey, args);
        }

        protected override void AddHtmlToCache(string cacheKey, string html, RenderRenderingArgs args) {
            //Check if the Timeout key is in the cache key; if not, try the base/Sitecore version
            if (!cacheKey.Contains(Settings.TimeoutRenderingKey)) {
                base.AddHtmlToCache(cacheKey, html, args);
                return;
            }

            //Check if the Timeout value is present and a valid TimeSpan; if not, try the base/Sitecore version
            bool timeoutValue = TimeSpan.TryParse(args.Rendering.RenderingItem.InnerItem[Settings.TimeoutRenderingParam], out _);

            if (!timeoutValue) {
                base.AddHtmlToCache(cacheKey, html, args);
                return;
            }

            //Check if the caching site exists; if not, try the base/Sitecore version
            SiteContext cachingSite = SiteContext.GetSite(Settings.CachingSite);

            if (cachingSite == null) {
                base.AddHtmlToCache(cacheKey, html, args);
                return;
            }

            //Check if the HTML cache is available in the caching site; if not, try the base/Sitecore version
            HtmlCache htmlCache = CacheManager.GetHtmlCache(cachingSite);

            if (htmlCache == null) {
                base.AddHtmlToCache(cacheKey, html, args);
                return;
            }

            //If everything is good, set the timeout based on the rendering parameter and put the HTML in the caching site
            TimeSpan timeout = GetTimeout(args);
            htmlCache.SetHtml(cacheKey, html, timeout);
        }

        protected override TimeSpan GetTimeout(RenderRenderingArgs args) => TimeSpan.TryParse(args.Rendering.RenderingItem.InnerItem[Settings.TimeoutRenderingParam], out TimeSpan result) ? result : args.Rendering.Caching.Timeout;
    }
}
```
#### Step 5: RenderFromCache
This is the "getter" code. If the timeout cache key is present, the code will check our caching site for the HTML.

```
using System.IO;
using Sitecore.Abstractions;
using Sitecore.Caching;
using Sitecore.Mvc.Pipelines.Response.RenderRendering;
using Sitecore.Mvc.Presentation;
using Sitecore.Sites;

namespace Foundation.Caching.Pipelines.Renderings {
    public class RenderFromCache : Sitecore.Mvc.Pipelines.Response.RenderRendering.RenderFromCache {
        public RenderFromCache(RendererCache rendererCache, BaseClient baseClient) : base(rendererCache, baseClient) {
        }

        protected override bool Render(string cacheKey, TextWriter writer, RenderRenderingArgs args) {
            //Check if the Timeout key is in the cache key; if not, try the base/Sitecore version
            if (!cacheKey.Contains(Settings.TimeoutRenderingKey)) {
                return base.Render(cacheKey, writer, args);
            }

            //Check if the caching site exists; if not, try the base/Sitecore version
            SiteContext cachingSite = SiteContext.GetSite(Settings.CachingSite);

            if (cachingSite == null) {
                return base.Render(cacheKey, writer, args);
            }

            //Check if an HTML cache exists for the caching site and that the HTML is present; if not, try the base/Sitecore version
            HtmlCache htmlCache = CacheManager.GetHtmlCache(cachingSite);
            string html = htmlCache?.GetHtml(cacheKey);

            if (html == null) {
                return base.Render(cacheKey, writer, args);
            }

            //If everything is good, write the HTML from the cache
            writer.Write(html);
            
            return true;
        }
    }
}
```

#### Step 6: Configuring the Pipelines
Finally, you need to wire all of this up with a patch file, overriding the appropriate built-in pipelines.

```
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/" xmlns:env="http://www.sitecore.net/xmlconfig/env/">
  <sitecore>
      <pipelines>
            <mvc.renderRendering>
                <processor type="Foundation.Caching.Pipelines.Renderings.RenderFromCache, Foundation.Caching" resolve="true" patch:instead="processor[@type='Sitecore.Mvc.Pipelines.Response.RenderRendering.RenderFromCache, Sitecore.Mvc']" />
                <processor type="Foundation.Caching.Pipelines.Renderings.AddRecordedHtmlToCache, Foundation.Caching" patch:instead="processor[@type='Sitecore.Mvc.Pipelines.Response.RenderRendering.AddRecordedHtmlToCache, Sitecore.Mvc']" />
                <processor type="Foundation.Caching.Pipelines.Renderings.GenerateCacheKey, Foundation.Caching" resolve="true" patch:instead="processor[@type='Sitecore.Mvc.Pipelines.Response.RenderRendering.GenerateCacheKey, Sitecore.Mvc']" />
            </mvc.renderRendering>
      </pipelines>
  </sitecore>
</configuration>
```

### **The Code!**

I've created a Github project with the code. There is a Sitecore package that has the new field, which you can then add in via whatever serialization method you prefer. Please see the readme file for more information.

{% github kmac23va/Foundation.Caching %}